Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ let package = Package(
path: "Sources/DesignAlgorithmsKit",
exclude: [
// Exclude hash/crypto types for WASM builds (they use NSLock)
"Algorithms/DataStructures/BloomFilter.swift",
"Algorithms/DataStructures/MerkleTree.swift",
"Algorithms/WASMGuard.swift"
]
),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import Foundation
import CryptoKit

/// Provides simplified Hybrid Encryption (Asymmetric Key Exchange + Symmetric Encryption).
///
/// Uses Curve25519 for Key Agreement and ChaCha20-Poly1305 for symmetric encryption.
///
/// Format: [Ephemeral Public Key (32 bytes)] + [ChaCha20-Poly1305 Sealed Box]
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public struct HybridEncryption {

// MARK: - Constants

private static let protocolSalt = "DesignAlgorithmsKit.HybridEncryption.v1".data(using: .utf8)!

// MARK: - Encryption

/// Encrypts data for a specific recipient using their public key.
///
/// - Parameters:
/// - data: The raw data to encrypt.
/// - recipientPublicKey: The recipient's Curve25519 Public Key.
/// - Returns: The encrypted data packet containing the ephemeral key and ciphertext.
public static func encrypt(_ data: Data, to recipientPublicKey: Curve25519.KeyAgreement.PublicKey) throws -> Data {
// 1. Generate Ephemeral Keypair
let ephemeralPrivateKey = Curve25519.KeyAgreement.PrivateKey()
let ephemeralPublicKey = ephemeralPrivateKey.publicKey

// 2. Derive Shared Secret
let sharedSecret = try ephemeralPrivateKey.sharedSecretFromKeyAgreement(with: recipientPublicKey)

// 3. Derive Symmetric Key (HKDF)
let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey(
using: CryptoKit.SHA256.self,
salt: protocolSalt,
sharedInfo: Data(),
outputByteCount: 32
)

// 4. Encrypt (ChaCha20-Poly1305)
let sealedBox = try ChaChaPoly.seal(data, using: symmetricKey)

// 5. Pack: [Ephemeral PubKey (32)] + [Sealed Box]
return ephemeralPublicKey.rawRepresentation + sealedBox.combined
}

// MARK: - Decryption

/// Decrypts a hybrid encryption packet.
///
/// - Parameters:
/// - encryptedData: The data packet ([Diffie-Hellman Key] + [Ciphertext]).
/// - privateKey: The recipient's Curve25519 Private Key.
/// - Returns: The original raw data.
public static func decrypt(_ encryptedData: Data, with privateKey: Curve25519.KeyAgreement.PrivateKey) throws -> Data {
guard encryptedData.count > 32 else {
throw CryptoError.invalidDataLength
}

// 1. Extract Ephemeral Public Key (First 32 bytes)
let ephemeralKeyData = encryptedData.prefix(32)
let ephemeralPublicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: ephemeralKeyData)

// 2. Extract Sealed Box (Remaining bytes)
let sealedBoxData = encryptedData.dropFirst(32)
let sealedBox = try ChaChaPoly.SealedBox(combined: sealedBoxData)

// 3. Derive Shared Secret
let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: ephemeralPublicKey)

// 4. Derive Symmetric Key (Must match encryption derivation)
let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey(
using: CryptoKit.SHA256.self,
salt: protocolSalt,
sharedInfo: Data(),
outputByteCount: 32
)

// 5. Open Box
return try ChaChaPoly.open(sealedBox, using: symmetricKey)
}
}

public enum CryptoError: Error {
case invalidDataLength
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public extension HashAlgorithmProtocol {
/// - Returns: Hash value as Data, or empty Data if UTF-8 conversion fails
/// - Note: UTF-8 conversion failure returns empty Data, which will hash to a valid hash value.
/// This path is testable by creating strings that fail UTF-8 conversion (rare but possible).
public static func hash(string: String) -> Data {
static func hash(string: String) -> Data {
guard let data = string.data(using: .utf8) else {
// UTF-8 conversion failed - return hash of empty data
// This is a valid fallback that ensures we always return a hash
Expand Down
118 changes: 118 additions & 0 deletions Sources/DesignAlgorithmsKit/Algorithms/Identification/ULID.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import Foundation

/// Lightweight ULID generator used for portable identifiers.
/// - SeeAlso: [ULID Specification](https://github.com/ulid/spec)
public struct ULID: Sendable, Equatable, Hashable, Codable, CustomStringConvertible {
private static let encoding = Array("0123456789ABCDEFGHJKMNPQRSTVWXYZ")
private static let decoding: [Character: UInt8] = {
var table: [Character: UInt8] = [:]
for (index, char) in encoding.enumerated() {
table[char] = UInt8(index)
}
return table
}()

private let bytes: [UInt8] // 16 bytes

public init(date: Date = Date(), randomBytes: [UInt8]? = nil) {
let timestamp = UInt64(date.timeIntervalSince1970 * 1000)
var data = [UInt8](repeating: 0, count: 16)

for i in stride(from: 5, through: 0, by: -1) {
data[5 - i] = UInt8((timestamp >> (i * 8)) & 0xFF)
}

var randomData = randomBytes ?? (0..<10).map { _ in UInt8.random(in: 0...255) }
if randomData.count != 10 {
randomData = Array(randomData.prefix(10))
if randomData.count < 10 {
randomData.append(contentsOf: Array(repeating: 0, count: 10 - randomData.count))
}
}
data.replaceSubrange(6..<16, with: randomData)
self.bytes = data
}

public init?(string: String) {
guard let decoded = ULID.decode(string: string) else {
return nil
}
self.bytes = decoded
}

public var description: String {
ULID.encode(bytes: bytes)
}

public var string: String { description }

// MARK: Codable

public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let value = try container.decode(String.self)
guard let decoded = ULID(string: value) else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ULID string.")
}
self = decoded
}

public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(description)
}

// MARK: Helpers

private static func encode(bytes: [UInt8]) -> String {
precondition(bytes.count == 16, "ULID must be 16 bytes.")
var output: [Character] = []
var buffer = 0
var bitsLeft = 0

for byte in bytes {
buffer = (buffer << 8) | Int(byte)
bitsLeft += 8
while bitsLeft >= 5 {
let index = (buffer >> (bitsLeft - 5)) & 0x1F
output.append(encoding[index])
bitsLeft -= 5
}
}

if bitsLeft > 0 {
let index = (buffer << (5 - bitsLeft)) & 0x1F
output.append(encoding[index])
}

while output.count < 26 {
output.append(encoding[0])
}

return String(output.prefix(26))
}

private static func decode(string: String) -> [UInt8]? {
guard string.count == 26 else { return nil }
var output: [UInt8] = []
var buffer = 0
var bitsLeft = 0

for char in string.uppercased() {
guard let value = decoding[char] else { return nil }
buffer = (buffer << 5) | Int(value)
bitsLeft += 5

if bitsLeft >= 8 {
let byte = UInt8((buffer >> (bitsLeft - 8)) & 0xFF)
output.append(byte)
bitsLeft -= 8
}
}

if output.count != 16 {
return nil
}
return output
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//
// GitObjectLayout.swift
// DesignAlgorithmsKit
//
// Created for DesignAlgorithmsKit
//

import Foundation

/// Git-Style Directory Layout Strategy
///
/// Implements the directory layout strategy used by Git for loose objects,
/// where a content hash is split into a directory (prefix) and filename (remainder).
///
/// Example:
/// Hash: "a1b2c3d4..."
/// Directory: "a1"
/// Filename: "b2c3d4..."
/// Path: "a1/b2c3d4..."
public struct GitObjectLayout {

/// The length of the prefix used for the directory name
public static let prefixLength = 2

/// Generates the layout components for a given hash.
/// - Parameter hash: The hex string representation of the hash.
/// - Returns: A tuple containing the directory, filename, and combined relative path.
/// If the hash is too short (<= prefixLength), returns empty directory and the hash as filename.
public static func layout(for hash: String) -> (directory: String, filename: String, path: String) {
guard hash.count > Self.prefixLength else {
return (directory: "", filename: hash, path: hash)
}

let prefixIndex = hash.index(hash.startIndex, offsetBy: Self.prefixLength)
let prefix = String(hash[..<prefixIndex])
let suffix = String(hash[prefixIndex...])

// Ensure path separator is handled by caller or returned as standard relative path
let path = "\(prefix)/\(suffix)"

return (directory: prefix, filename: suffix, path: path)
}

/// Generates the relative path for a given hash.
/// - Parameter hash: The hex string representation of the hash.
/// - Returns: The relative path string (e.g., "ab/cdef...").
public static func path(for hash: String) -> String {
return layout(for: hash).path
}
}
2 changes: 1 addition & 1 deletion Sources/DesignAlgorithmsKit/Core/Registry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ open class Registry<Key: Hashable, Value>: @unchecked Sendable {
/// Unregister a key
/// - Parameter key: Key to remove
open func unregister(_ key: Key) {
storage.write { $0.removeValue(forKey: key) }
_ = storage.write { $0.removeValue(forKey: key) }
}

/// Get all values
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@
// DesignAlgorithmsKitTests
//
// Unit tests for Bloom Filter
// NOTE: Tests disabled as BloomFilter.swift is excluded from the package
//

/*
import XCTest
@testable import DesignAlgorithmsKit

Expand Down Expand Up @@ -133,4 +131,3 @@ final class BloomFilterTests: XCTestCase {
XCTAssertEqual(filter.elementCount, 0)
}
}
*/
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import XCTest
@testable import DesignAlgorithmsKit

final class GitObjectLayoutTests: XCTestCase {

func testLayoutGeneration() {
let hash = "a1b2c3d4e5f6"
let layout = GitObjectLayout.layout(for: hash)

XCTAssertEqual(layout.directory, "a1")
XCTAssertEqual(layout.filename, "b2c3d4e5f6")
XCTAssertEqual(layout.path, "a1/b2c3d4e5f6")
}

func testShortHash() {
let hash = "ab"
let layout = GitObjectLayout.layout(for: hash)

// Expect behavior for short hashes (implementation specific: no split)
XCTAssertEqual(layout.path, "ab")
XCTAssertEqual(layout.directory, "")
XCTAssertEqual(layout.filename, "ab")

let hash2 = "a"
let layout2 = GitObjectLayout.layout(for: hash2)
XCTAssertEqual(layout2.path, "a")
XCTAssertEqual(layout2.directory, "")
XCTAssertEqual(layout2.filename, "a")
}

func testPathHelper() {
let hash = "1234567890"
XCTAssertEqual(GitObjectLayout.path(for: hash), "12/34567890")
}

func testStandardGitHash() {
// Example SHA-1 hash
let hash = "5e80dc522e0327ba4944d180bbf261904e545805"
let layout = GitObjectLayout.layout(for: hash)

XCTAssertEqual(layout.directory, "5e")
XCTAssertEqual(layout.filename, "80dc522e0327ba4944d180bbf261904e545805")
XCTAssertEqual(layout.path, "5e/80dc522e0327ba4944d180bbf261904e545805")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// HashAlgorithmProtocolTests.swift
// DesignAlgorithmsKitTests
//
// Unit tests for Hash Algorithm Protocol default implementations
//

import XCTest
@testable import DesignAlgorithmsKit

final class HashAlgorithmProtocolTests: XCTestCase {

struct MockHash: HashAlgorithmProtocol {
static let name = "Mock"

static func hash(data: Data) -> Data {
return data
}
// Uses default hash(string:) implementation
}

func testDefaultStringHash() {
let input = "test"
let expectedData = input.data(using: .utf8)!

// Should call default implementation which calls hash(data:)
let result = MockHash.hash(string: input)

XCTAssertEqual(result, expectedData)
}

func testSHA256Hash() {
let data = "test".data(using: .utf8)!
let hash = SHA256.hash(data: data)
XCTAssertEqual(hash.count, 32)
}
}
Loading