]> git.saurik.com Git - apple/security.git/blob - keychain/ckks/CKKSAccountStateTracker.m
Security-59754.41.1.tar.gz
[apple/security.git] / keychain / ckks / CKKSAccountStateTracker.m
1 /*
2 * Copyright (c) 2017 Apple Inc. All Rights Reserved.
3 *
4 * @APPLE_LICENSE_HEADER_START@
5 *
6 * This file contains Original Code and/or Modifications of Original Code
7 * as defined in and that are subject to the Apple Public Source License
8 * Version 2.0 (the 'License'). You may not use this file except in
9 * compliance with the License. Please obtain a copy of the License at
10 * http://www.opensource.apple.com/apsl/ and read it before using this
11 * file.
12 *
13 * The Original Code and all software distributed under the License are
14 * distributed on an 'AS IS' basis, WITHOUT WARRANTY OF ANY KIND, EITHER
15 * EXPRESS OR IMPLIED, AND APPLE HEREBY DISCLAIMS ALL SUCH WARRANTIES,
16 * INCLUDING WITHOUT LIMITATION, ANY WARRANTIES OF MERCHANTABILITY,
17 * FITNESS FOR A PARTICULAR PURPOSE, QUIET ENJOYMENT OR NON-INFRINGEMENT.
18 * Please see the License for the specific language governing rights and
19 * limitations under the License.
20 *
21 * @APPLE_LICENSE_HEADER_END@
22 */
23
24 #if OCTAGON
25
26 #include <dispatch/dispatch.h>
27 #include <Security/SecureObjectSync/SOSCloudCircle.h>
28 #include <Security/SecureObjectSync/SOSCloudCircleInternal.h>
29 #include "keychain/SecureObjectSync/SOSInternal.h"
30 #include <notify.h>
31
32 #import "keychain/ot/OTManager.h"
33 #import "keychain/ot/OTConstants.h"
34 #import "keychain/ckks/CKKS.h"
35 #import "keychain/ckks/CloudKitCategories.h"
36 #import "keychain/ckks/CKKSAccountStateTracker.h"
37 #import "keychain/ckks/CKKSAnalytics.h"
38 #import "keychain/categories/NSError+UsefulConstructors.h"
39 #import "keychain/ot/ObjCImprovements.h"
40
41
42 NSString* CKKSAccountStatusToString(CKKSAccountStatus status)
43 {
44 switch(status) {
45 case CKKSAccountStatusAvailable:
46 return @"available";
47 case CKKSAccountStatusNoAccount:
48 return @"no account";
49 case CKKSAccountStatusUnknown:
50 return @"unknown";
51 }
52 }
53
54 @interface CKKSAccountStateTracker ()
55 @property (readonly) Class<CKKSNSNotificationCenter> nsnotificationCenterClass;
56
57 @property dispatch_queue_t queue;
58
59 @property NSMapTable<dispatch_queue_t, id<CKKSCloudKitAccountStateListener>>* ckChangeListeners;
60
61 @property CKContainer* container; // used only for fetching the CKAccountStatus
62 @property bool firstCKAccountFetch;
63
64 // make writable
65 @property (nullable) OTCliqueStatusWrapper* octagonStatus;
66 @property (nullable) NSString* octagonPeerID;
67 @property CKKSCondition* octagonInformationInitialized;
68
69 @property CKKSAccountStatus hsa2iCloudAccountStatus;
70 @property CKKSCondition* hsa2iCloudAccountInitialized;
71 @end
72
73 @implementation CKKSAccountStateTracker
74 @synthesize octagonPeerID = _octagonPeerID;
75
76 -(instancetype)init: (CKContainer*) container nsnotificationCenterClass: (Class<CKKSNSNotificationCenter>) nsnotificationCenterClass {
77 if((self = [super init])) {
78 _nsnotificationCenterClass = nsnotificationCenterClass;
79 // These map tables are backwards from how we'd like, but it's the best way to have weak pointers to CKKSCombinedAccountStateListener.
80 _ckChangeListeners = [NSMapTable strongToWeakObjectsMapTable];
81
82 _currentCKAccountInfo = nil;
83 _ckAccountInfoInitialized = [[CKKSCondition alloc] init];
84
85 _currentCircleStatus = [[SOSAccountStatus alloc] init:kSOSCCError error:nil];
86
87 _container = container;
88
89 _queue = dispatch_queue_create("ck-account-state", DISPATCH_QUEUE_SERIAL_WITH_AUTORELEASE_POOL);
90
91 _firstCKAccountFetch = false;
92
93 _finishedInitialDispatches = [[CKKSCondition alloc] init];
94 _ckdeviceIDInitialized = [[CKKSCondition alloc] init];
95
96 _octagonInformationInitialized = [[CKKSCondition alloc] init];
97
98 _hsa2iCloudAccountStatus = CKKSAccountStatusUnknown;
99 _hsa2iCloudAccountInitialized = [[CKKSCondition alloc] init];
100
101 id<CKKSNSNotificationCenter> notificationCenter = [self.nsnotificationCenterClass defaultCenter];
102 ckksinfo_global("ckksaccount", "Registering with notification center %@", notificationCenter);
103 [notificationCenter addObserver:self selector:@selector(notifyCKAccountStatusChange:) name:CKAccountChangedNotification object:NULL];
104
105 WEAKIFY(self);
106
107 // If this is a live server, register with notify
108 if(!SecCKKSTestsEnabled()) {
109 int token = 0;
110 notify_register_dispatch(kSOSCCCircleChangedNotification, &token, dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^(int t) {
111 STRONGIFY(self);
112 [self notifyCircleChange:nil];
113 });
114
115 // Fire off a fetch of the account status. Do not go on our local queue, because notifyCKAccountStatusChange will attempt to go back on it for thread-safety.
116 // Note: if you're in the tests, you must call performInitialDispatches yourself!
117 dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
118 STRONGIFY(self);
119 if(!self) {
120 return;
121 }
122 @autoreleasepool {
123 [self performInitialDispatches];
124 }
125 });
126 }
127 }
128 return self;
129 }
130
131 - (void)performInitialDispatches
132 {
133 @autoreleasepool {
134 [self notifyCKAccountStatusChange:nil];
135 [self notifyCircleChange:nil];
136 [self.finishedInitialDispatches fulfill];
137 }
138 }
139
140 -(void)dealloc {
141 id<CKKSNSNotificationCenter> notificationCenter = [self.nsnotificationCenterClass defaultCenter];
142 [notificationCenter removeObserver:self];
143 }
144
145 -(NSString*)descriptionInternal: (NSString*) selfString {
146 return [NSString stringWithFormat:@"<%@: %@, hsa2: %@>",
147 selfString,
148 self.currentCKAccountInfo,
149 CKKSAccountStatusToString(self.hsa2iCloudAccountStatus)];
150 }
151
152 -(NSString*)description {
153 return [self descriptionInternal: [[self class] description]];
154 }
155
156 -(NSString*)debugDescription {
157 return [self descriptionInternal: [super description]];
158 }
159
160 - (dispatch_semaphore_t)registerForNotificationsOfCloudKitAccountStatusChange:(id<CKKSCloudKitAccountStateListener>)listener {
161 // signals when we've successfully delivered the first account status
162 dispatch_semaphore_t finishedSema = dispatch_semaphore_create(0);
163
164 dispatch_async(self.queue, ^{
165 bool alreadyRegisteredListener = false;
166 NSEnumerator *enumerator = [self.ckChangeListeners objectEnumerator];
167 id<CKKSCloudKitAccountStateListener> value;
168
169 while ((value = [enumerator nextObject])) {
170 // do pointer comparison
171 alreadyRegisteredListener |= (value == listener);
172 }
173
174 if(listener && !alreadyRegisteredListener) {
175 NSString* queueName = [NSString stringWithFormat: @"ck-account-state-%@", listener];
176
177 dispatch_queue_t objQueue = dispatch_queue_create([queueName UTF8String], DISPATCH_QUEUE_SERIAL_WITH_AUTORELEASE_POOL);
178 [self.ckChangeListeners setObject:listener forKey: objQueue];
179
180 ckksinfo_global("ckksaccount-ck", "adding a new listener: %@", listener);
181
182 // If we know the current account status, let this listener know
183 if(self.firstCKAccountFetch) {
184 ckksinfo_global("ckksaccount-ck", "notifying new listener %@ of current state %@", listener, self.currentCKAccountInfo);
185
186 dispatch_group_t g = dispatch_group_create();
187 if(!g) {
188 ckkserror_global("ckksaccount-ck", "Unable to get dispatch group.");
189 dispatch_semaphore_signal(finishedSema);
190 return;
191 }
192
193 [self _onqueueDeliverCurrentCloudKitState:listener listenerQueue:objQueue oldStatus:nil group:g];
194
195 dispatch_group_notify(g, self.queue, ^{
196 dispatch_semaphore_signal(finishedSema);
197 });
198 } else {
199 dispatch_semaphore_signal(finishedSema);
200 }
201 } else {
202 dispatch_semaphore_signal(finishedSema);
203 }
204 });
205
206 return finishedSema;
207 }
208
209 - (dispatch_semaphore_t)notifyCKAccountStatusChange:(__unused id)object {
210 // signals when this notify is Complete, including all downcalls.
211 dispatch_semaphore_t finishedSema = dispatch_semaphore_create(0);
212
213 WEAKIFY(self);
214
215 [self.container accountInfoWithCompletionHandler:^(CKAccountInfo* ckAccountInfo, NSError * _Nullable error) {
216 STRONGIFY(self);
217
218 if(error) {
219 ckkserror_global("ckksaccount", "error getting account info: %@", error);
220 dispatch_semaphore_signal(finishedSema);
221 return;
222 }
223
224 dispatch_sync(self.queue, ^{
225 self.firstCKAccountFetch = true;
226 ckksnotice_global("ckksaccount", "received CK Account info: %@", ckAccountInfo);
227 [self _onqueueUpdateAccountState:ckAccountInfo deliveredSemaphore:finishedSema];
228 });
229 }];
230
231 return finishedSema;
232 }
233
234 // Takes the new ckAccountInfo we're moving to
235 -(void)_onqueueUpdateCKDeviceID:(CKAccountInfo*)ckAccountInfo {
236 dispatch_assert_queue(self.queue);
237 WEAKIFY(self);
238
239 // If we're in an account, opportunistically fill in the device id
240 if(ckAccountInfo.accountStatus == CKAccountStatusAvailable) {
241 [self.container fetchCurrentDeviceIDWithCompletionHandler:^(NSString* deviceID, NSError* ckerror) {
242 STRONGIFY(self);
243 if(!self) {
244 ckkserror_global("ckksaccount", "Received fetchCurrentDeviceIDWithCompletionHandler callback with null AccountStateTracker");
245 return;
246 }
247
248 // Make sure you synchronize here; if we've logged out before the callback returns, don't record the result
249 dispatch_async(self.queue, ^{
250 STRONGIFY(self);
251 if(self.currentCKAccountInfo.accountStatus == CKAccountStatusAvailable) {
252 ckksnotice_global("ckksaccount", "CloudKit deviceID is: %@ %@", deviceID, ckerror);
253
254 self.ckdeviceID = deviceID;
255 self.ckdeviceIDError = ckerror;
256 [self.ckdeviceIDInitialized fulfill];
257 } else {
258 // Logged out! No ckdeviceid.
259 ckkserror_global("ckksaccount", "Logged back out but still received a fetchCurrentDeviceIDWithCompletionHandler callback");
260
261 self.ckdeviceID = nil;
262 self.ckdeviceIDError = nil;
263 // Don't touch the ckdeviceIDInitialized object; it should have been reset when the logout happened.
264 }
265 });
266 }];
267 } else {
268 // Logging out? no more device ID.
269 self.ckdeviceID = nil;
270 self.ckdeviceIDError = nil;
271 self.ckdeviceIDInitialized = [[CKKSCondition alloc] init];
272 }
273 }
274
275 - (dispatch_semaphore_t)notifyCircleChange:(__unused id)object {
276 dispatch_semaphore_t finishedSema = dispatch_semaphore_create(0);
277
278 SOSAccountStatus* sosstatus = [CKKSAccountStateTracker getCircleStatus];
279 dispatch_sync(self.queue, ^{
280 if(self.currentCircleStatus == nil || ![self.currentCircleStatus isEqual:sosstatus]) {
281 ckksnotice_global("ckksaccount", "moving to circle status: %@", sosstatus);
282 self.currentCircleStatus = sosstatus;
283
284 if (sosstatus.status == kSOSCCInCircle) {
285 [[CKKSAnalytics logger] setDateProperty:[NSDate date] forKey:CKKSAnalyticsLastInCircle];
286 }
287 [self _onqueueUpdateCirclePeerID:sosstatus];
288 }
289 dispatch_semaphore_signal(finishedSema);
290 });
291
292 return finishedSema;
293 }
294
295 // Pulled out for mocking purposes
296 + (void)fetchCirclePeerID:(void (^)(NSString* _Nullable peerID, NSError* _Nullable error))callback {
297 dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
298 CFErrorRef cferror = nil;
299 SOSPeerInfoRef egoPeerInfo = SOSCCCopyMyPeerInfo(&cferror);
300 NSString* egoPeerID = egoPeerInfo ? (NSString*)CFBridgingRelease(CFRetainSafe(SOSPeerInfoGetPeerID(egoPeerInfo))) : nil;
301 CFReleaseNull(egoPeerInfo);
302
303 callback(egoPeerID, CFBridgingRelease(cferror));
304 });
305 }
306
307 // Takes the new ckAccountInfo we're moving to
308 - (void)_onqueueUpdateCirclePeerID:(SOSAccountStatus*)sosstatus {
309 dispatch_assert_queue(self.queue);
310 WEAKIFY(self);
311
312 // If we're in a circle, fetch the peer id
313 if(sosstatus.status == kSOSCCInCircle) {
314 [CKKSAccountStateTracker fetchCirclePeerID:^(NSString* peerID, NSError* error) {
315 STRONGIFY(self);
316 if(!self) {
317 ckkserror_global("ckksaccount", "Received fetchCirclePeerID callback with null AccountStateTracker");
318 return;
319 }
320
321 dispatch_async(self.queue, ^{
322 STRONGIFY(self);
323
324 if(self.currentCircleStatus && self.currentCircleStatus.status == kSOSCCInCircle) {
325 ckksnotice_global("ckksaccount", "Circle peerID is: %@ %@", peerID, error);
326 // Still in circle. Proceed.
327 self.accountCirclePeerID = peerID;
328 self.accountCirclePeerIDError = error;
329 [self.accountCirclePeerIDInitialized fulfill];
330 } else {
331 ckkserror_global("ckksaccount", "Out of circle but still received a fetchCirclePeerID callback");
332 // Not in-circle. Throw away circle id.
333 self.accountCirclePeerID = nil;
334 self.accountCirclePeerIDError = nil;
335 // Don't touch the accountCirclePeerIDInitialized object; it should have been reset when the logout happened.
336 }
337 });
338 }];
339 } else {
340 // Not in-circle, reset circle ID
341 ckksnotice_global("ckksaccount", "out of circle(%@): resetting peer ID", sosstatus);
342 self.accountCirclePeerID = nil;
343 self.accountCirclePeerIDError = nil;
344 self.accountCirclePeerIDInitialized = [[CKKSCondition alloc] init];
345 }
346 }
347
348 - (void)_onqueueUpdateAccountState:(CKAccountInfo*)ckAccountInfo
349 deliveredSemaphore:(dispatch_semaphore_t)finishedSema
350 {
351 // Launder the finishedSema into a dispatch_group.
352 // _onqueueUpdateAccountState:circle:dispatchGroup: will then add any blocks it thinks is necessary,
353 // then the group will fire the semaphore.
354 dispatch_assert_queue(self.queue);
355
356 dispatch_group_t g = dispatch_group_create();
357 if(!g) {
358 ckksnotice_global("ckksaccount", "Unable to get dispatch group.");
359 dispatch_semaphore_signal(finishedSema);
360 return;
361 }
362
363 [self _onqueueUpdateAccountState:ckAccountInfo
364 dispatchGroup:g];
365
366 dispatch_group_notify(g, self.queue, ^{
367 dispatch_semaphore_signal(finishedSema);
368 });
369 }
370
371 - (void)_onqueueUpdateAccountState:(CKAccountInfo*)ckAccountInfo
372 dispatchGroup:(dispatch_group_t)g
373 {
374 dispatch_assert_queue(self.queue);
375
376 if([self.currentCKAccountInfo isEqual: ckAccountInfo]) {
377 // no-op.
378 ckksinfo_global("ckksaccount", "received another notification of CK Account State %@", ckAccountInfo);
379 return;
380 }
381
382 if((self.currentCKAccountInfo == nil && ckAccountInfo != nil) ||
383 !(self.currentCKAccountInfo == ckAccountInfo || [self.currentCKAccountInfo isEqual: ckAccountInfo])) {
384 ckksnotice_global("ckksaccount", "moving to CK Account info: %@", ckAccountInfo);
385 CKAccountInfo* oldAccountInfo = self.currentCKAccountInfo;
386 self.currentCKAccountInfo = ckAccountInfo;
387 [self.ckAccountInfoInitialized fulfill];
388
389 [self _onqueueUpdateCKDeviceID: ckAccountInfo];
390
391 [self _onqueueDeliverCloudKitStateChanges:oldAccountInfo dispatchGroup:g];
392 }
393 }
394
395 - (void)_onqueueDeliverCloudKitStateChanges:(CKAccountInfo*)oldStatus
396 dispatchGroup:(dispatch_group_t)g
397 {
398 dispatch_assert_queue(self.queue);
399
400 NSEnumerator *enumerator = [self.ckChangeListeners keyEnumerator];
401 dispatch_queue_t dq;
402
403 // Queue up the changes for each listener.
404 while ((dq = [enumerator nextObject])) {
405 id<CKKSCloudKitAccountStateListener> listener = [self.ckChangeListeners objectForKey: dq];
406 [self _onqueueDeliverCurrentCloudKitState:listener listenerQueue:dq oldStatus:oldStatus group:g];
407 }
408 }
409
410 - (void)_onqueueDeliverCurrentCloudKitState:(id<CKKSCloudKitAccountStateListener>)listener
411 listenerQueue:(dispatch_queue_t)listenerQueue
412 oldStatus:(CKAccountInfo* _Nullable)oldStatus
413 group:(dispatch_group_t)g
414 {
415 dispatch_assert_queue(self.queue);
416
417 __weak __typeof(listener) weakListener = listener;
418
419 if(listener) {
420 dispatch_group_async(g, listenerQueue, ^{
421 [weakListener cloudkitAccountStateChange:oldStatus to:self.currentCKAccountInfo];
422 });
423 }
424 }
425
426 - (BOOL)notifyCKAccountStatusChangeAndWait:(dispatch_time_t)timeout
427 {
428 return dispatch_semaphore_wait([self notifyCKAccountStatusChange:nil], dispatch_time(DISPATCH_TIME_NOW, timeout)) == 0;
429 }
430
431 -(void)notifyCKAccountStatusChangeAndWaitForSignal {
432 [self notifyCKAccountStatusChangeAndWait:DISPATCH_TIME_FOREVER];
433 }
434
435 -(void)notifyCircleStatusChangeAndWaitForSignal {
436 dispatch_semaphore_wait([self notifyCircleChange: nil], DISPATCH_TIME_FOREVER);
437 }
438
439 -(dispatch_group_t)checkForAllDeliveries {
440
441 dispatch_group_t g = dispatch_group_create();
442 if(!g) {
443 ckksnotice_global("ckksaccount", "Unable to get dispatch group.");
444 return nil;
445 }
446
447 dispatch_sync(self.queue, ^{
448 NSEnumerator *enumerator = [self.ckChangeListeners keyEnumerator];
449 dispatch_queue_t dq;
450
451 // Queue up the changes for each listener.
452 while ((dq = [enumerator nextObject])) {
453 id<CKKSCloudKitAccountStateListener> listener = [self.ckChangeListeners objectForKey: dq];
454
455 ckksinfo_global("ckksaccountblock", "Starting blocking for listener %@", listener);
456 WEAKIFY(listener);
457 dispatch_group_async(g, dq, ^{
458 STRONGIFY(listener);
459 // Do nothing in particular. It's just important that this block runs.
460 ckksinfo_global("ckksaccountblock", "Done blocking for listener %@", listener);
461 });
462 }
463 });
464
465 return g;
466 }
467
468 // This is its own function to allow OCMock to swoop in and replace the result during testing.
469 + (SOSAccountStatus*)getCircleStatus {
470 CFErrorRef cferror = NULL;
471
472 SOSCCStatus status = SOSCCThisDeviceIsInCircle(&cferror);
473 if(cferror) {
474 ckkserror_global("ckksaccount", "error getting circle status: %@", cferror);
475 return [[SOSAccountStatus alloc] init:kSOSCCError error:CFBridgingRelease(cferror)];
476 }
477
478 return [[SOSAccountStatus alloc] init:status error:nil];
479 }
480
481 + (NSString*)stringFromAccountStatus: (CKKSAccountStatus) status {
482 switch(status) {
483 case CKKSAccountStatusUnknown: return @"account state unknown";
484 case CKKSAccountStatusAvailable: return @"logged in";
485 case CKKSAccountStatusNoAccount: return @"no account";
486 }
487 }
488
489 - (void)triggerOctagonStatusFetch
490 {
491 WEAKIFY(self);
492
493 __block CKKSCondition* blockPointer = nil;
494 dispatch_sync(self.queue, ^{
495 self.octagonInformationInitialized = [[CKKSCondition alloc] initToChain:self.octagonInformationInitialized];
496 blockPointer = self.octagonInformationInitialized;
497 });
498
499 // Explicitly do not use the OTClique API, as that might include SOS status as well
500 OTOperationConfiguration* config = [[OTOperationConfiguration alloc] init];
501 config.timeoutWaitForCKAccount = 100*NSEC_PER_MSEC;
502 [[OTManager manager] fetchTrustStatus:nil
503 context:OTDefaultContext
504 configuration:config
505 reply:^(CliqueStatus status, NSString * _Nullable peerID, NSNumber * _Nullable numberOfPeersInOctagon, BOOL isExcluded, NSError * _Nullable error) {
506 STRONGIFY(self);
507
508 dispatch_sync(self.queue, ^{
509 if(error) {
510 ckkserror_global("ckksaccount", "error getting octagon status: %@", error);
511 self.octagonStatus = [[OTCliqueStatusWrapper alloc] initWithStatus:CliqueStatusError];
512 } else {
513 ckksnotice_global("ckksaccount", "Caching octagon status as (%@, %@)", OTCliqueStatusToString(status), peerID);
514 self.octagonStatus = [[OTCliqueStatusWrapper alloc] initWithStatus:status];
515 }
516
517 self.octagonPeerID = peerID;
518 [blockPointer fulfill];
519 });
520 }];
521 }
522
523
524 - (void)setHSA2iCloudAccountStatus:(CKKSAccountStatus)status
525 {
526 self.hsa2iCloudAccountStatus = status;
527 if(status == CKKSAccountStatusUnknown) {
528 self.hsa2iCloudAccountInitialized = [[CKKSCondition alloc] initToChain:self.hsa2iCloudAccountInitialized];
529 } else {
530 [self.hsa2iCloudAccountInitialized fulfill];
531 }
532 }
533
534 @end
535
536 @implementation SOSAccountStatus
537 - (instancetype)init:(SOSCCStatus)status error:(NSError*)error
538 {
539 if((self = [super init])) {
540 _status = status;
541 _error = error;
542 }
543 return self;
544 }
545
546 - (BOOL)isEqual:(id)object
547 {
548 if(![object isKindOfClass:[SOSAccountStatus class]]) {
549 return NO;
550 }
551
552 if(object == nil) {
553 return NO;
554 }
555
556 SOSAccountStatus* obj = (SOSAccountStatus*) object;
557 return self.status == obj.status &&
558 ((self.error == nil && obj.error == nil) || [self.error isEqual:obj.error]);
559 }
560
561 - (NSString*)description
562 {
563 return [NSString stringWithFormat:@"<SOSStatus: %@ (%@)>", SOSCCGetStatusDescription(self.status), self.error];
564 }
565 @end
566
567
568 @implementation OTCliqueStatusWrapper
569 - (instancetype)initWithStatus:(CliqueStatus)status
570 {
571 if((self = [super init])) {
572 _status = status;
573 }
574 return self;
575 }
576
577 - (BOOL)isEqual:(id)object
578 {
579 if(![object isKindOfClass:[OTCliqueStatusWrapper class]]) {
580 return NO;
581 }
582
583 OTCliqueStatusWrapper* obj = (OTCliqueStatusWrapper*)object;
584 return obj.status == self.status;
585 }
586 - (NSString*)description
587 {
588 return [NSString stringWithFormat:@"<CliqueStatus: %@>", OTCliqueStatusToString(self.status)];
589 }
590 @end
591
592
593 #endif // OCTAGON