Files
damus/damus/NIP44/NIP44.swift
Daniel D’Aquino a6e123e928 Remove rust-nostr dependency
This commit removes rust-nostr dependency, and replaces the NIP-44 logic
with a new NIP-44 module based on the Swift NostrSDK implementation.

The decision to move away from rust-nostr and the Swift NostrSDK was
made for the following reasons:
1. `rust-nostr` caused the app size to double
2. We only need NIP44 functionality, and we don't need to bring
   everything else
3. The Swift NostrSDK caused conflicts around the secp256k1 dependency
   that is hard to address
4. The way we do things in the codebase is far different from the Swift
   NostrSDK, and we optimize it for use with NostrDB. Bringing it an
   outside library causes significant complexity in integration with
   NostrDB, and would effectively cause the codebase to be split into
   two different ways of achieving the same results. Therefore it is
   cleaner if we stick to our own Nostr structures and functions and
   focus on maintaining them.

However, the library CryptoSwift was added as a dependency, to bring in
ChaCha20 which is not supported by CryptoKit (CryptoKit supports the
ChaCha20-Poly1305 cipher, but NIP-44 uses ChaCha20 with HMAC-SHA256
instead)

Closes: https://github.com/damus-io/damus/issues/2849
Changelog-Changed: Made internal changes to reduce the app binary size
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-02-12 11:05:58 -08:00

358 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// NIP44.swift
// damus
//
// Based on NIP44v2Encrypting.swift created by Terry Yiu on 3/16/24, from https://github.com/nostr-sdk/nostr-sdk-ios, which is MIT licensed.
//
// MIT License
//
// Copyright (c) 2023 Nostr SDK
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
//
// Adapted by Daniel DAquino on 2025-02-10.
//
import Foundation
import CryptoKit
import CryptoSwift
import secp256k1
struct NIP44v2Encryption {
/// Produces a `String` containing `plaintext` that has been encrypted using the `privateKey` of user A and the `publicKey` of user B.
///
/// The result is non-deterministic because a cryptographically secure pseudorandom generated nonce is used each time,
/// but can be decrypted deterministically with a call to ``NIP44v2Encryption/decrypt(payload:privateKeyA:publicKeyB:)``,
/// where user A and user B are interchangeable.
///
/// This function can `throw` an error from ``EncryptionError`` if it fails to encrypt the plaintext.
///
/// - Parameters:
/// - plaintext: The plaintext to encrypt.
/// - privateKeyA: The private key of user A.
/// - publicKeyB: The public key of user B.
/// - Returns: The encrypted ciphertext.
static func encrypt(plaintext: String, privateKeyA: Privkey, publicKeyB: Pubkey) throws -> String {
let conversationKey = try conversationKey(privateKeyA: privateKeyA, publicKeyB: publicKeyB)
return try encrypt(plaintext: plaintext, conversationKey: conversationKey)
}
/// Produces a `String` containing `payload` that has been decrypted using the `privateKey` of user A and the `publicKey` of user B,
/// and the result is identical to if the `privateKey` of user B and `publicKey` of user A were used to decrypt `payload` instead.
///
/// Any ciphertext returned from the call to ``NIP44v2Encryption/encrypt(plaintext:privateKeyA:publicKeyB:)``
/// can be decrypted, where user A and B are interchangeable.
///
/// This function can `throw` an error from ``EncryptionError`` if it fails to decrypt the payload.
///
/// - Parameters:
/// - payload: The payload to decrypt.
/// - privateKeyA: The private key of user A.
/// - publicKeyB: The public key of user B.
/// - Returns: The decrypted plaintext message.
static func decrypt(payload: String, privateKeyA: Privkey, publicKeyB: Pubkey) throws -> String {
let conversationKey = try conversationKey(privateKeyA: privateKeyA, publicKeyB: publicKeyB)
return try decrypt(payload: payload, conversationKey: conversationKey)
}
/// Calculates length of the padded byte array.
static func calculatePaddedLength(_ unpaddedLength: Int) throws -> Int {
guard unpaddedLength > 0 else {
throw EncryptionError.unpaddedLengthInvalid(unpaddedLength)
}
if unpaddedLength <= 32 {
return 32
}
let nextPower = 1 << (Int(floor(log2(Double(unpaddedLength) - 1))) + 1)
let chunk: Int
if nextPower <= 256 {
chunk = 32
} else {
chunk = nextPower / 8
}
return chunk * (Int(floor((Double(unpaddedLength) - 1) / Double(chunk))) + 1)
}
/// Converts unpadded plaintext to padded bytes.
static func pad(_ plaintext: String) throws -> Data {
guard let unpadded = plaintext.data(using: .utf8) else {
throw EncryptionError.utf8EncodingFailed
}
let unpaddedLength = unpadded.count
guard 1...65535 ~= unpaddedLength else {
throw EncryptionError.plaintextLengthInvalid(unpaddedLength)
}
var prefix = Data(count: 2)
prefix.withUnsafeMutableBytes { (ptr: UnsafeMutableRawBufferPointer) in
ptr.storeBytes(of: UInt16(unpaddedLength).bigEndian, as: UInt16.self)
}
let suffix = Data(count: try calculatePaddedLength(unpaddedLength) - unpaddedLength)
return prefix + unpadded + suffix
}
/// Converts padded bytes to unpadded plaintext.
static func unpad(_ padded: Data) throws -> String {
guard padded.count >= 2 else {
throw EncryptionError.paddingInvalid
}
let unpaddedLength = (Int(padded[0]) << 8) | Int(padded[1])
guard 2+unpaddedLength <= padded.count else {
throw EncryptionError.paddingInvalid
}
let unpadded = toBytes(from: padded)[2..<2+unpaddedLength]
let paddedLength = try calculatePaddedLength(unpaddedLength)
guard unpaddedLength > 0,
unpadded.count == unpaddedLength,
padded.count == 2 + paddedLength,
let result = String(data: Data(unpadded), encoding: .utf8) else {
throw EncryptionError.paddingInvalid
}
return result
}
static func decodePayload(_ payload: String) throws -> DecodedPayload {
let payloadLength = payload.count
guard payloadLength > 0 && payload.first != "#" else {
throw EncryptionError.unknownVersion()
}
guard 132...87472 ~= payloadLength else {
throw EncryptionError.payloadSizeInvalid(payloadLength)
}
guard let data = Data(base64Encoded: payload) else {
throw EncryptionError.base64EncodingFailed
}
let dataLength = data.count
guard 99...65603 ~= dataLength else {
throw EncryptionError.dataSizeInvalid(dataLength)
}
guard let version = data.first else {
throw EncryptionError.unknownVersion()
}
guard version == 2 else {
throw EncryptionError.unknownVersion(Int(version))
}
let nonce = data[data.index(data.startIndex, offsetBy: 1)..<data.index(data.startIndex, offsetBy: 33)]
let ciphertext = data[data.index(data.startIndex, offsetBy: 33)..<data.index(data.startIndex, offsetBy: dataLength - 32)]
let mac = data[data.index(data.startIndex, offsetBy: dataLength - 32)..<data.index(data.startIndex, offsetBy: dataLength)]
return DecodedPayload(nonce: nonce, ciphertext: ciphertext, mac: mac)
}
static func hmacAad(key: Data, message: Data, aad: Data) throws -> Data {
guard aad.count == 32 else {
throw EncryptionError.aadLengthInvalid(aad.count)
}
let combined = aad + message
return Data(CryptoKit.HMAC<CryptoKit.SHA256>.authenticationCode(for: combined, using: SymmetricKey(data: key)))
}
static func toBytes(from data: Data) -> [UInt8] {
data.withUnsafeBytes { bytesPointer in Array(bytesPointer) }
}
static func preparePublicKeyBytes(from publicKey: Pubkey) throws -> [UInt8] {
let publicKeyBytes = publicKey.bytes
let prefix = Data([2])
let prefixBytes = toBytes(from: prefix)
return prefixBytes + publicKeyBytes
}
static func parsePublicKey(from bytes: [UInt8]) throws -> secp256k1_pubkey {
var publicKey = secp256k1_pubkey()
guard secp256k1_ec_pubkey_parse(secp256k1.Context.raw, &publicKey, bytes, bytes.count) == 1 else {
throw EncryptionError.publicKeyInvalid
}
return publicKey
}
static func computeSharedSecret(using publicKey: secp256k1_pubkey, and privateKeyBytes: [UInt8]) throws -> [UInt8] {
var sharedSecret = [UInt8](repeating: 0, count: 32)
var mutablePublicKey = publicKey
// Multiplication of point B by scalar a (a B), defined in [BIP340](https://github.com/bitcoin/bips/blob/e918b50731397872ad2922a1b08a5a4cd1d6d546/bip-0340.mediawiki).
// The operation produces a shared point, and we encode the shared point's 32-byte x coordinate, using method bytes(P) from BIP340.
// Private and public keys must be validated as per BIP340: pubkey must be a valid, on-curve point, and private key must be a scalar in range [1, secp256k1_order - 1]
guard secp256k1_ecdh(secp256k1.Context.raw, &sharedSecret, &mutablePublicKey, privateKeyBytes, { (output, x32, _, _) in
memcpy(output, x32, 32)
return 1
}, nil) != 0 else {
throw EncryptionError.sharedSecretComputationFailed
}
return sharedSecret
}
/// Calculates long-term key between users A and B.
/// The conversation key of A's private key and B's public key is equal to the conversation key of B's private key and A's public key.
static func conversationKey(privateKeyA: Privkey, publicKeyB: Pubkey) throws -> ContiguousBytes {
let privateKeyABytes = privateKeyA.bytes
let publicKeyBBytes = try preparePublicKeyBytes(from: publicKeyB)
let parsedPublicKeyB = try parsePublicKey(from: publicKeyBBytes)
let sharedSecret = try computeSharedSecret(using: parsedPublicKeyB, and: privateKeyABytes)
return CryptoKit.HKDF<CryptoKit.SHA256>.extract(inputKeyMaterial: SymmetricKey(data: sharedSecret), salt: Data("nip44-v2".utf8))
}
/// Calculates unique per-message key.
static func messageKeys(conversationKey: ContiguousBytes, nonce: Data) throws -> MessageKeys {
let conversationKeyByteCount = conversationKey.bytes.count
guard conversationKeyByteCount == 32 else {
throw EncryptionError.conversationKeyLengthInvalid(conversationKeyByteCount)
}
guard nonce.count == 32 else {
throw EncryptionError.nonceLengthInvalid(nonce.count)
}
let keys = CryptoKit.HKDF<CryptoKit.SHA256>.expand(pseudoRandomKey: conversationKey, info: nonce, outputByteCount: 76)
let keysBytes = keys.bytes
let chaChaKey = Data(keysBytes[0..<32])
let chaChaNonce = Data(keysBytes[32..<44])
let hmacKey = Data(keysBytes[44..<76])
return MessageKeys(chaChaKey: chaChaKey, chaChaNonce: chaChaNonce, hmacKey: hmacKey)
}
static func encrypt(plaintext: String, conversationKey: ContiguousBytes, nonce: Data? = nil) throws -> String {
let nonceData: Data
if let nonce {
nonceData = nonce
} else {
// Fetches randomness from CSPRNG.
nonceData = Data.secureRandomBytes(count: 32)
}
let messageKeys = try messageKeys(conversationKey: conversationKey, nonce: nonceData)
let padded = try pad(plaintext)
let paddedBytes = toBytes(from: padded)
let chaChaKey = toBytes(from: messageKeys.chaChaKey)
let chaChaNonce = toBytes(from: messageKeys.chaChaNonce)
let ciphertext = try ChaCha20(key: chaChaKey, iv: chaChaNonce).encrypt(paddedBytes)
let ciphertextData = Data(ciphertext)
let mac = try hmacAad(key: messageKeys.hmacKey, message: ciphertextData, aad: nonceData)
let data = Data([2]) + nonceData + ciphertextData + mac
return data.base64EncodedString()
}
static func decrypt(payload: String, conversationKey: ContiguousBytes) throws -> String {
let decodedPayload = try decodePayload(payload)
let nonce = decodedPayload.nonce
let ciphertext = decodedPayload.ciphertext
let ciphertextBytes = toBytes(from: ciphertext)
let mac = decodedPayload.mac
let messageKeys = try messageKeys(conversationKey: conversationKey, nonce: nonce)
let calculatedMac = try hmacAad(key: messageKeys.hmacKey, message: ciphertext, aad: nonce)
guard calculatedMac == mac else {
throw EncryptionError.macInvalid
}
let chaChaNonce = toBytes(from: messageKeys.chaChaNonce)
let chaChaKey = toBytes(from: messageKeys.chaChaKey)
let paddedPlaintext = try ChaCha20(key: chaChaKey, iv: chaChaNonce).decrypt(ciphertextBytes)
let paddedPlaintextData = Data(paddedPlaintext.bytes)
return try unpad(paddedPlaintextData)
}
}
// MARK: - Helper structures and extensions
extension Data {
/// Random data of a given size, from CSPRNG
/// - Parameter count: The size of the data, in bytes
/// - Returns: Bytes randomly generated from CSPRNG
static func secureRandomBytes(count: Int) -> Data {
var bytes = [Int8](repeating: 0, count: count)
guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else {
fatalError("can't copy secure random data")
}
return Data(bytes: bytes, count: count)
}
}
extension NIP44v2Encryption {
struct DecodedPayload {
let nonce: Data
let ciphertext: Data
let mac: Data
}
struct MessageKeys {
let chaChaKey: Data
let chaChaNonce: Data
let hmacKey: Data
}
public enum EncryptionError: Error {
case aadLengthInvalid(Int)
case base64EncodingFailed
case chaCha20DecryptionFailed
case chaCha20EncryptionFailed
case conversationKeyLengthInvalid(Int)
case dataSizeInvalid(Int)
case macInvalid
case nonceLengthInvalid(Int)
case paddingInvalid
case payloadSizeInvalid(Int)
case plaintextLengthInvalid(Int)
case privateKeyInvalid
case publicKeyInvalid
case sharedSecretComputationFailed
case unknownVersion(Int? = nil)
case unpaddedLengthInvalid(Int)
case utf8EncodingFailed
}
}