1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106 | import Foundation
import CryptoKit
/// A user's long-lived identity: a 32-byte Ed25519 seed (43 URL-safe base64
/// characters). Optional in the protocol โ rooms work without it โ but it lets
/// peers recognise each other across sessions by fingerprint.
public struct Identity: Sendable {
private let privateKey: Curve25519.Signing.PrivateKey
/// Display name announced in the room hello (clamped by the receiver).
public var name: String?
/// Hue 0..<360 announced in the room hello.
public var hue: Int?
/// A single glyph (emoji) announced in the room hello.
public var glyph: String?
public static func generate() -> Identity {
Identity(privateKey: Curve25519.Signing.PrivateKey())
}
/// Rebuilds the identity from its 32-byte seed in URL-safe base64.
public init(seedBase64URL: String) throws {
guard let seed = Bytes.fromBase64URL(seedBase64URL), seed.count == 32,
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: seed)
else { throw IdentifierError.malformedSeed }
self.init(privateKey: key)
}
private init(privateKey: Curve25519.Signing.PrivateKey) {
self.privateKey = privateKey
}
/// The seed, for backup and device transfer. Treat it as a password.
public var seedBase64URL: String { Bytes.base64URL(privateKey.rawRepresentation) }
public var publicKey: Data { privateKey.publicKey.rawRepresentation }
public var publicKeyBase64URL: String { Bytes.base64URL(publicKey) }
public var fingerprint: Fingerprint { Fingerprint(publicKey: publicKey) }
/// Signs the room-session binding `sueta-id|v2|<appSalt>|<roomId>|<peerId>`.
/// Returns standard base64 (the reference encoding for assertions).
public func assert(appSalt: String, roomId: RoomId, peerId: PeerId) -> String {
Bytes.base64(signRaw(Identity.bindingBytes(appSalt: appSalt, roomId: roomId.string, peerId: peerId.hex)))
}
/// Raw Ed25519 signature (64 bytes).
public func signRaw(_ data: Data) -> Data {
// Ed25519 signing cannot fail for a valid key.
try! privateKey.signature(for: data)
}
static func bindingBytes(appSalt: String, roomId: String, peerId: String) -> Data {
Data("sueta-id|v2|\(appSalt)|\(roomId)|\(peerId)".utf8)
}
/// Verifies a peer's assertion; the fingerprint of the key on success, `nil` otherwise.
/// Accepts the signature in standard or URL-safe base64.
public static func verifyAssertion(appSalt: String, roomId: RoomId, peerId: PeerId,
publicKeyBase64URL: String, signature: String) -> Fingerprint? {
verifyAssertion(appSalt: appSalt, roomId: roomId.string, peerId: peerId.hex,
publicKeyBase64URL: publicKeyBase64URL, signature: signature)
}
static func verifyAssertion(appSalt: String, roomId: String, peerId: String,
publicKeyBase64URL: String, signature: String) -> Fingerprint? {
guard let pub = Bytes.fromBase64URL(publicKeyBase64URL),
let key = try? Curve25519.Signing.PublicKey(rawRepresentation: pub),
let sig = Bytes.fromBase64URL(signature.replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")),
key.isValidSignature(sig, for: bindingBytes(appSalt: appSalt, roomId: roomId, peerId: peerId))
else { return nil }
return Fingerprint(publicKey: pub)
}
/// Verifies a raw signature (URL-safe base64) over arbitrary bytes.
public static func verifyRaw(publicKeyBase64URL: String, signatureBase64URL: String, data: Data) -> Bool {
guard let pub = Bytes.fromBase64URL(publicKeyBase64URL),
let key = try? Curve25519.Signing.PublicKey(rawRepresentation: pub),
let sig = Bytes.fromBase64URL(signatureBase64URL)
else { return false }
return key.isValidSignature(sig, for: data)
}
}
/// SHA-256 of a public key: the full hex digest plus four emoji from its first
/// 24 bits, for humans to compare.
public struct Fingerprint: Sendable, Hashable, CustomStringConvertible {
public let emoji: String
public let hex: String
/// The 64-entry table is part of the wire format; several entries carry a
/// variation selector (U+FE0F) and must stay exactly as they are.
public static let emojiTable: [String] = [
"๐ข", "๐ฆ", "๐ผ", "๐ฆ", "๐ธ", "๐", "๐ฆ", "๐", "๐ฌ", "๐ฆ", "๐", "๐ฆ", "๐ต", "๐ฒ", "๐", "๐",
"๐ป", "๐", "โญ", "๐ฅ", "๐", "โก", "โ๏ธ", "๐", "๐", "๐", "๐", "๐", "๐ฅ", "๐ฝ", "๐ฅ", "๐ฉ",
"๐", "๐ฅจ", "๐ง", "๐ฟ", "โ", "๐ต", "๐ง", "๐", "๐ฒ", "๐ฏ", "๐ธ", "๐บ", "๐ฅ", "๐", "๐ฒ", "โต",
"๐", "๐ฟ", "๐๏ธ", "๐", "๐งฒ", "๐งญ", "โณ", "๐", "๐๏ธ", "๐", "โ๏ธ", "๐", "๐", "๐ก๏ธ", "โ๏ธ", "๐",
]
public init(publicKey: Data) {
let h = Bytes.sha256(publicKey)
let bits = (UInt32(h[0]) << 16) | (UInt32(h[1]) << 8) | UInt32(h[2])
let t = Fingerprint.emojiTable
emoji = t[Int((bits >> 18) & 63)] + t[Int((bits >> 12) & 63)] + t[Int((bits >> 6) & 63)] + t[Int(bits & 63)]
hex = Bytes.hex(h)
}
public var description: String { emoji }
}
|