1// HiddenServiceKeyWriter.swift
2// SophaxChat
3//
4// Writes a deterministic Tor v3 hidden service key file from an Ed25519 identity seed.
5//
6// By pre-writing the key file before Tor starts, we guarantee that the hidden service
7// .onion address equals OnionAddress.from(ed25519PublicKey:) — i.e. the address is a
8// stable function of the user's identity key, requiring no out-of-band communication.
9//
10// Tor v3 hidden service key file format (`hs_ed25519_secret_key`):
11// [0..31] = "== ed25519v1-secret: type0 ==\0\0\0" (fixed ASCII header, 29 bytes + 3 NUL)
12// [32..95] = expanded Ed25519 private key (64 bytes = SHA-512(seed) with clamping)
13//
14// Reference: torspec/rend-spec-v3.txt §A.2
15
16import Foundation
17import CryptoKit
18
19enum HiddenServiceKeyWriter {
20
21 // 32-byte ASCII header: "== ed25519v1-secret: type0 ==\0\0\0"
22 private static let fileHeader: [UInt8] = {
23 let ascii = Array("== ed25519v1-secret: type0 ==".utf8) // 29 bytes
24 return ascii + [0, 0, 0] // + 3 NUL bytes = 32 bytes total
25 }()
26
27 /// Writes `hs_ed25519_secret_key` and `hs_ed25519_public_key` into `directory`.
28 /// Creates the directory if it doesn't exist. Safe to call repeatedly — overwrites.
29 ///
30 /// - Parameters:
31 /// - directory: The hidden service directory (e.g. `<tor_data>/hidden_service/`).
32 /// - seed: 32-byte Ed25519 private key seed (raw representation of the signing key).
33 static func write(to directory: URL, seed: Data) throws {
34 precondition(seed.count == 32, "Ed25519 seed must be 32 bytes")
35
36 try FileManager.default.createDirectory(at: directory,
37 withIntermediateDirectories: true,
38 attributes: [.posixPermissions: 0o700])
39
40 // 1. Derive expanded private key: SHA-512(seed) with Ed25519 scalar clamping.
41 let expanded = expandEd25519Seed(seed)
42
43 // 2. Write secret key file (96 bytes).
44 var secretFileBytes = [UInt8](fileHeader) // 32-byte header
45 secretFileBytes.append(contentsOf: expanded) // 64-byte expanded key
46 let secretKeyURL = directory.appendingPathComponent("hs_ed25519_secret_key")
47 try Data(secretFileBytes).write(to: secretKeyURL, options: .atomic)
48
49 // 3. Write public key file (64 bytes = same header + 32-byte public key).
50 // Tor reads this to verify the key pair; we derive it from the signing key.
51 let signingKey = try Curve25519.Signing.PrivateKey(rawRepresentation: seed)
52 let pubkeyBytes = signingKey.publicKey.rawRepresentation
53 var pubKeyFileBytes = [UInt8](fileHeader)
54 pubKeyFileBytes.append(contentsOf: pubkeyBytes)
55 let pubKeyURL = directory.appendingPathComponent("hs_ed25519_public_key")
56 try Data(pubKeyFileBytes).write(to: pubKeyURL, options: .atomic)
57 }
58
59 // MARK: - Ed25519 seed expansion
60
61 /// Expands a 32-byte Ed25519 seed into a 64-byte expanded private key via SHA-512,
62 /// then applies the standard Ed25519 scalar clamping.
63 private static func expandEd25519Seed(_ seed: Data) -> [UInt8] {
64 // SHA-512(seed) → 64 bytes
65 var h = Array(Data(SHA512.hash(data: seed)))
66
67 // Ed25519 clamping (RFC 8032 §5.1.5):
68 // h[0] &= 248 (clear the bottom 3 bits)
69 // h[31] &= 127 (clear the top bit)
70 // h[31] |= 64 (set the second-highest bit)
71 h[0] &= 248
72 h[31] &= 127
73 h[31] |= 64
74
75 return h
76 }
77}