]>
Commit | Line | Data |
---|---|---|
b54c578e A |
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 | #import <Foundation/NSKeyedArchiver_Private.h> | |
27 | ||
28 | #import "keychain/ckks/CKKSTLKShareRecord.h" | |
29 | #import "keychain/ckks/CKKSPeer.h" | |
30 | #import "keychain/ckks/CloudKitCategories.h" | |
31 | #import "keychain/categories/NSError+UsefulConstructors.h" | |
32 | ||
33 | #import <SecurityFoundation/SFKey.h> | |
34 | #import <SecurityFoundation/SFEncryptionOperation.h> | |
35 | #import <SecurityFoundation/SFSigningOperation.h> | |
36 | #import <SecurityFoundation/SFDigestOperation.h> | |
37 | ||
38 | @interface CKKSTLKShareRecord () | |
39 | @end | |
40 | ||
41 | @implementation CKKSTLKShareRecord | |
42 | ||
43 | - (instancetype)init:(CKKSTLKShare*)share | |
44 | zoneID:(CKRecordZoneID*)zoneID | |
45 | encodedCKRecord:(NSData*)encodedCKRecord | |
46 | { | |
47 | if((self = [super initWithCKRecordType:SecCKRecordTLKShareType | |
48 | encodedCKRecord:encodedCKRecord | |
49 | zoneID:zoneID])) { | |
50 | _share = share; | |
51 | } | |
52 | return self; | |
53 | } | |
54 | ||
55 | -(instancetype)init:(CKKSKey*)key | |
56 | sender:(id<CKKSSelfPeer>)sender | |
57 | receiver:(id<CKKSPeer>)receiver | |
58 | curve:(SFEllipticCurve)curve | |
59 | version:(SecCKKSTLKShareVersion)version | |
60 | epoch:(NSInteger)epoch | |
61 | poisoned:(NSInteger)poisoned | |
62 | zoneID:(CKRecordZoneID*)zoneID | |
63 | encodedCKRecord:(NSData*)encodedCKRecord | |
64 | { | |
65 | if((self = [super initWithCKRecordType:SecCKRecordTLKShareType | |
66 | encodedCKRecord:encodedCKRecord | |
67 | zoneID:zoneID])) { | |
68 | ||
69 | _share = [[CKKSTLKShare alloc] init:key.keycore | |
70 | sender:sender | |
71 | receiver:receiver | |
72 | curve:curve | |
73 | version:version | |
74 | epoch:epoch | |
75 | poisoned:poisoned | |
76 | zoneID:zoneID]; | |
77 | } | |
78 | return self; | |
79 | } | |
80 | ||
81 | - (instancetype)initForKey:(NSString*)tlkUUID | |
82 | senderPeerID:(NSString*)senderPeerID | |
83 | recieverPeerID:(NSString*)receiverPeerID | |
84 | receiverEncPublicKeySPKI:(NSData*)publicKeySPKI | |
85 | curve:(SFEllipticCurve)curve | |
86 | version:(SecCKKSTLKShareVersion)version | |
87 | epoch:(NSInteger)epoch | |
88 | poisoned:(NSInteger)poisoned | |
89 | wrappedKey:(NSData*)wrappedKey | |
90 | signature:(NSData*)signature | |
91 | zoneID:(CKRecordZoneID*)zoneID | |
92 | encodedCKRecord:(NSData*)encodedCKRecord | |
93 | { | |
94 | if((self = [super initWithCKRecordType:SecCKRecordTLKShareType | |
95 | encodedCKRecord:encodedCKRecord | |
96 | zoneID:zoneID])) { | |
97 | ||
98 | _share = [[CKKSTLKShare alloc] initForKey:tlkUUID | |
99 | senderPeerID:senderPeerID | |
100 | recieverPeerID:receiverPeerID | |
101 | receiverEncPublicKeySPKI:publicKeySPKI | |
102 | curve:curve | |
103 | version:version | |
104 | epoch:epoch | |
105 | poisoned:poisoned | |
106 | wrappedKey:wrappedKey | |
107 | signature:signature | |
108 | zoneID:zoneID]; | |
109 | } | |
110 | return self; | |
111 | } | |
112 | ||
113 | - (NSString*)description { | |
114 | return [NSString stringWithFormat:@"<CKKSTLKShare(%@): recv:%@ send:%@>", | |
115 | self.share.tlkUUID, | |
116 | self.share.receiverPeerID, | |
117 | self.share.senderPeerID]; | |
118 | } | |
119 | ||
120 | - (NSString*)tlkUUID | |
121 | { | |
122 | return self.share.tlkUUID; | |
123 | } | |
124 | ||
125 | - (NSString*)senderPeerID | |
126 | { | |
127 | return self.share.senderPeerID; | |
128 | } | |
129 | - (NSInteger)epoch | |
130 | { | |
131 | return self.share.epoch; | |
132 | } | |
133 | ||
134 | - (NSInteger)poisoned | |
135 | { | |
136 | return self.share.poisoned; | |
137 | } | |
138 | ||
139 | - (NSData*)wrappedTLK | |
140 | { | |
141 | return self.share.wrappedTLK; | |
142 | } | |
143 | - (NSData*)signature | |
144 | { | |
145 | return self.share.signature; | |
146 | } | |
147 | ||
148 | - (CKKSKey*)unwrapUsing:(id<CKKSSelfPeer>)localPeer | |
149 | error:(NSError * __autoreleasing *)error | |
150 | { | |
151 | CKKSKeychainBackedKey* realkey = [self.share unwrapUsing:localPeer | |
152 | error:error]; | |
153 | ||
154 | if(!realkey) { | |
155 | return nil; | |
156 | } | |
157 | ||
158 | return [[CKKSKey alloc] initWithKeyCore:realkey]; | |
159 | } | |
160 | ||
161 | - (NSData*)dataForSigning | |
162 | { | |
163 | return [self.share dataForSigning:self.storedCKRecord]; | |
164 | } | |
165 | ||
166 | // Returns the signature, but not the signed data itself; | |
167 | - (NSData*)signRecord:(SFECKeyPair*)signingKey | |
168 | error:(NSError* __autoreleasing *)error | |
169 | { | |
170 | return [self.share signRecord:signingKey | |
171 | ckrecord:self.storedCKRecord | |
172 | error:error]; | |
173 | } | |
174 | ||
175 | - (bool)verifySignature:(NSData*)signature | |
176 | verifyingPeer:(id<CKKSPeer>)peer | |
177 | error:(NSError* __autoreleasing *)error | |
178 | { | |
179 | return [self.share verifySignature:signature | |
180 | verifyingPeer:peer | |
181 | ckrecord:self.storedCKRecord | |
182 | error:error]; | |
183 | } | |
184 | ||
185 | - (bool)signatureVerifiesWithPeerSet:(NSSet<id<CKKSPeer>>*)peerSet | |
186 | error:(NSError**)error | |
187 | { | |
188 | return [self.share signatureVerifiesWithPeerSet:peerSet | |
189 | ckrecord:self.storedCKRecord | |
190 | error:error]; | |
191 | } | |
192 | ||
193 | - (instancetype)copyWithZone:(NSZone *)zone { | |
194 | CKKSTLKShareRecord* shareRecord = [[[self class] allocWithZone:zone] init]; | |
195 | shareRecord.share = [self.share copyWithZone:zone]; | |
196 | return shareRecord; | |
197 | } | |
198 | ||
199 | - (BOOL)isEqual:(id)object { | |
200 | if(![object isKindOfClass:[CKKSTLKShareRecord class]]) { | |
201 | return NO; | |
202 | } | |
203 | ||
204 | CKKSTLKShareRecord* obj = (CKKSTLKShareRecord*) object; | |
205 | return [self.share isEqual: obj.share]; | |
206 | } | |
207 | ||
208 | + (CKKSTLKShareRecord*)share:(CKKSKey*)key | |
209 | as:(id<CKKSSelfPeer>)sender | |
210 | to:(id<CKKSPeer>)receiver | |
211 | epoch:(NSInteger)epoch | |
212 | poisoned:(NSInteger)poisoned | |
213 | error:(NSError* __autoreleasing *)error | |
214 | { | |
215 | NSError* localerror = nil; | |
216 | // Load any existing TLK Share, so we can update it | |
217 | CKKSTLKShareRecord* oldShare = [CKKSTLKShareRecord tryFromDatabase:key.uuid | |
218 | receiverPeerID:receiver.peerID | |
219 | senderPeerID:sender.peerID | |
220 | zoneID:key.zoneID | |
221 | error:&localerror]; | |
222 | if(localerror) { | |
223 | secerror("ckksshare: couldn't load old share for %@: %@", key, localerror); | |
224 | if(error) { | |
225 | *error = localerror; | |
226 | } | |
227 | return nil; | |
228 | } | |
229 | ||
230 | CKKSTLKShare* share = [CKKSTLKShare share:key.keycore | |
231 | as:sender | |
232 | to:receiver | |
233 | epoch:epoch | |
234 | poisoned:poisoned | |
235 | error:error]; | |
236 | if(!share) { | |
237 | return nil; | |
238 | } | |
239 | ||
240 | CKKSTLKShareRecord* sharerecord = [[CKKSTLKShareRecord alloc] init:share | |
241 | zoneID:key.zoneID | |
242 | encodedCKRecord:oldShare.encodedCKRecord]; | |
243 | return sharerecord; | |
244 | } | |
245 | ||
246 | - (CKKSKey*)recoverTLK:(id<CKKSSelfPeer>)recoverer | |
247 | trustedPeers:(NSSet<id<CKKSPeer>>*)peers | |
248 | error:(NSError* __autoreleasing *)error | |
249 | { | |
250 | CKKSKeychainBackedKey* realkey = [self.share recoverTLK:recoverer | |
251 | trustedPeers:peers | |
252 | ckrecord:self.storedCKRecord | |
253 | error:error]; | |
254 | if(!realkey) { | |
255 | return nil; | |
256 | } | |
257 | return [[CKKSKey alloc] initWithKeyCore:realkey]; | |
258 | } | |
259 | ||
260 | #pragma mark - Database Operations | |
261 | ||
262 | + (instancetype)fromDatabase:(NSString*)uuid | |
263 | receiverPeerID:(NSString*)receiverPeerID | |
264 | senderPeerID:(NSString*)senderPeerID | |
265 | zoneID:(CKRecordZoneID*)zoneID | |
266 | error:(NSError * __autoreleasing *)error { | |
267 | return [self fromDatabaseWhere: @{@"uuid":CKKSNilToNSNull(uuid), | |
268 | @"recvpeerid":CKKSNilToNSNull(receiverPeerID), | |
269 | @"senderpeerid":CKKSNilToNSNull(senderPeerID), | |
270 | @"ckzone": CKKSNilToNSNull(zoneID.zoneName)} error:error]; | |
271 | } | |
272 | ||
273 | + (instancetype)tryFromDatabase:(NSString*)uuid | |
274 | receiverPeerID:(NSString*)receiverPeerID | |
275 | senderPeerID:(NSString*)senderPeerID | |
276 | zoneID:(CKRecordZoneID*)zoneID | |
277 | error:(NSError * __autoreleasing *)error { | |
278 | return [self tryFromDatabaseWhere: @{@"uuid":CKKSNilToNSNull(uuid), | |
279 | @"recvpeerid":CKKSNilToNSNull(receiverPeerID), | |
280 | @"senderpeerid":CKKSNilToNSNull(senderPeerID), | |
281 | @"ckzone": CKKSNilToNSNull(zoneID.zoneName)} error:error]; | |
282 | } | |
283 | ||
284 | + (NSArray<CKKSTLKShareRecord*>*)allFor:(NSString*)receiverPeerID | |
285 | keyUUID:(NSString*)uuid | |
286 | zoneID:(CKRecordZoneID*)zoneID | |
287 | error:(NSError * __autoreleasing *)error { | |
288 | return [self allWhere:@{@"recvpeerid":CKKSNilToNSNull(receiverPeerID), | |
289 | @"uuid":uuid, | |
290 | @"ckzone": CKKSNilToNSNull(zoneID.zoneName)} error:error]; | |
291 | } | |
292 | ||
293 | + (NSArray<CKKSTLKShareRecord*>*)allForUUID:(NSString*)uuid | |
294 | zoneID:(CKRecordZoneID*)zoneID | |
295 | error:(NSError * __autoreleasing *)error { | |
296 | return [self allWhere:@{@"uuid":CKKSNilToNSNull(uuid), | |
297 | @"ckzone":CKKSNilToNSNull(zoneID.zoneName)} error:error]; | |
298 | } | |
299 | ||
300 | + (NSArray<CKKSTLKShareRecord*>*)allInZone:(CKRecordZoneID*)zoneID | |
301 | error:(NSError * __autoreleasing *)error { | |
302 | return [self allWhere:@{@"ckzone": CKKSNilToNSNull(zoneID.zoneName)} error:error]; | |
303 | } | |
304 | ||
305 | + (instancetype)tryFromDatabaseFromCKRecordID:(CKRecordID*)recordID | |
306 | error:(NSError * __autoreleasing *)error { | |
307 | // Welp. Try to parse! | |
308 | NSError *localerror = NULL; | |
309 | NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"^tlkshare-(?<uuid>[0-9A-Fa-f-]*)::(?<receiver>.*)::(?<sender>.*)$" | |
310 | options:NSRegularExpressionCaseInsensitive | |
311 | error:&localerror]; | |
312 | if(localerror) { | |
313 | if(error) { | |
314 | *error = localerror; | |
315 | } | |
316 | return nil; | |
317 | } | |
318 | ||
319 | NSTextCheckingResult* regexmatch = [regex firstMatchInString:recordID.recordName options:0 range:NSMakeRange(0, recordID.recordName.length)]; | |
320 | if(!regexmatch) { | |
321 | if(error) { | |
322 | *error = [NSError errorWithDomain:CKKSErrorDomain | |
323 | code:CKKSNoSuchRecord | |
324 | description:[NSString stringWithFormat:@"Couldn't parse '%@' as a TLKShare ID", recordID.recordName]]; | |
325 | } | |
326 | return nil; | |
327 | } | |
328 | ||
329 | NSString* uuid = [recordID.recordName substringWithRange:[regexmatch rangeWithName:@"uuid"]]; | |
330 | NSString* receiver = [recordID.recordName substringWithRange:[regexmatch rangeWithName:@"receiver"]]; | |
331 | NSString* sender = [recordID.recordName substringWithRange:[regexmatch rangeWithName:@"sender"]]; | |
332 | ||
333 | return [self tryFromDatabaseWhere: @{@"uuid":CKKSNilToNSNull(uuid), | |
334 | @"recvpeerid":CKKSNilToNSNull(receiver), | |
335 | @"senderpeerid":CKKSNilToNSNull(sender), | |
336 | @"ckzone": CKKSNilToNSNull(recordID.zoneID.zoneName)} error:error]; | |
337 | } | |
338 | ||
339 | #pragma mark - CKKSCKRecordHolder methods | |
340 | ||
341 | + (NSString*)ckrecordPrefix { | |
342 | return @"tlkshare"; | |
343 | } | |
344 | ||
345 | - (NSString*)CKRecordName { | |
346 | return [NSString stringWithFormat:@"tlkshare-%@::%@::%@", self.share.tlkUUID, self.share.receiverPeerID, self.share.senderPeerID]; | |
347 | } | |
348 | ||
349 | - (CKRecord*)updateCKRecord:(CKRecord*)record zoneID:(CKRecordZoneID*)zoneID { | |
350 | if(![record.recordID.recordName isEqualToString: [self CKRecordName]]) { | |
351 | @throw [NSException | |
352 | exceptionWithName:@"WrongCKRecordNameException" | |
353 | reason:[NSString stringWithFormat: @"CKRecord name (%@) was not %@", record.recordID.recordName, [self CKRecordName]] | |
354 | userInfo:nil]; | |
355 | } | |
356 | if(![record.recordType isEqualToString: SecCKRecordTLKShareType]) { | |
357 | @throw [NSException | |
358 | exceptionWithName:@"WrongCKRecordTypeException" | |
359 | reason:[NSString stringWithFormat: @"CKRecordType (%@) was not %@", record.recordType, SecCKRecordTLKShareType] | |
360 | userInfo:nil]; | |
361 | } | |
362 | ||
363 | record[SecCKRecordSenderPeerID] = self.share.senderPeerID; | |
364 | record[SecCKRecordReceiverPeerID] = self.share.receiverPeerID; | |
365 | record[SecCKRecordReceiverPublicEncryptionKey] = [self.share.receiverPublicEncryptionKeySPKI base64EncodedStringWithOptions:0]; | |
366 | record[SecCKRecordCurve] = [NSNumber numberWithUnsignedInteger:(NSUInteger)self.share.curve]; | |
367 | record[SecCKRecordVersion] = [NSNumber numberWithUnsignedInteger:(NSUInteger)self.share.version]; | |
368 | record[SecCKRecordEpoch] = [NSNumber numberWithLong:(long)self.share.epoch]; | |
369 | record[SecCKRecordPoisoned] = [NSNumber numberWithLong:(long)self.share.poisoned]; | |
370 | ||
371 | record[SecCKRecordParentKeyRefKey] = [[CKReference alloc] initWithRecordID: [[CKRecordID alloc] initWithRecordName: self.share.tlkUUID zoneID: zoneID] | |
372 | action: CKReferenceActionValidate]; | |
373 | ||
374 | record[SecCKRecordWrappedKeyKey] = [self.share.wrappedTLK base64EncodedStringWithOptions:0]; | |
375 | record[SecCKRecordSignature] = [self.share.signature base64EncodedStringWithOptions:0]; | |
376 | ||
377 | return record; | |
378 | } | |
379 | ||
380 | - (bool)matchesCKRecord:(CKRecord*)record { | |
381 | if(![record.recordType isEqualToString: SecCKRecordTLKShareType]) { | |
382 | return false; | |
383 | } | |
384 | ||
385 | if(![record.recordID.recordName isEqualToString: [self CKRecordName]]) { | |
386 | return false; | |
387 | } | |
388 | ||
389 | CKKSTLKShareRecord* share = [[CKKSTLKShareRecord alloc] initWithCKRecord:record]; | |
390 | return [self isEqual: share]; | |
391 | } | |
392 | ||
393 | - (void)setFromCKRecord: (CKRecord*) record { | |
394 | if(![record.recordType isEqualToString: SecCKRecordTLKShareType]) { | |
395 | @throw [NSException | |
396 | exceptionWithName:@"WrongCKRecordTypeException" | |
397 | reason:[NSString stringWithFormat: @"CKRecordType (%@) was not %@", record.recordType, SecCKRecordDeviceStateType] | |
398 | userInfo:nil]; | |
399 | } | |
400 | ||
401 | [self setStoredCKRecord:record]; | |
402 | ||
403 | NSData* pubkeydata = CKKSUnbase64NullableString(record[SecCKRecordReceiverPublicEncryptionKey]); | |
404 | ||
405 | self.share = [[CKKSTLKShare alloc] initForKey:((CKReference*)record[SecCKRecordParentKeyRefKey]).recordID.recordName | |
406 | senderPeerID:record[SecCKRecordSenderPeerID] | |
407 | recieverPeerID:record[SecCKRecordReceiverPeerID] | |
408 | receiverEncPublicKeySPKI:pubkeydata | |
409 | curve:[record[SecCKRecordCurve] longValue] // TODO: sanitize | |
410 | version:[record[SecCKRecordVersion] longValue] | |
411 | epoch:[record[SecCKRecordEpoch] longValue] | |
412 | poisoned:[record[SecCKRecordPoisoned] longValue] | |
413 | wrappedKey:[[NSData alloc] initWithBase64EncodedString:record[SecCKRecordWrappedKeyKey] options:0] | |
414 | signature:[[NSData alloc] initWithBase64EncodedString:record[SecCKRecordSignature] options:0] | |
415 | zoneID:record.recordID.zoneID]; | |
416 | } | |
417 | ||
418 | #pragma mark - CKKSSQLDatabaseObject methods | |
419 | ||
420 | + (NSString*)sqlTable { | |
421 | return @"tlkshare"; | |
422 | } | |
423 | ||
424 | + (NSArray<NSString*>*)sqlColumns { | |
425 | return @[@"ckzone", @"uuid", @"senderpeerid", @"recvpeerid", @"recvpubenckey", @"poisoned", @"epoch", @"curve", @"version", @"wrappedkey", @"signature", @"ckrecord"]; | |
426 | } | |
427 | ||
428 | - (NSDictionary<NSString*,NSString*>*)whereClauseToFindSelf { | |
429 | return @{@"uuid":self.share.tlkUUID, | |
430 | @"senderpeerid":self.share.senderPeerID, | |
431 | @"recvpeerid":self.share.receiverPeerID, | |
432 | @"ckzone":self.zoneID.zoneName, | |
433 | }; | |
434 | } | |
435 | ||
436 | - (NSDictionary<NSString*,NSString*>*)sqlValues { | |
437 | return @{@"uuid": self.share.tlkUUID, | |
438 | @"senderpeerid": self.share.senderPeerID, | |
439 | @"recvpeerid": self.share.receiverPeerID, | |
440 | @"recvpubenckey": CKKSNilToNSNull([self.share.receiverPublicEncryptionKeySPKI base64EncodedStringWithOptions:0]), | |
441 | @"poisoned": [NSString stringWithFormat:@"%ld", (long)self.share.poisoned], | |
442 | @"epoch": [NSString stringWithFormat:@"%ld", (long)self.share.epoch], | |
443 | @"curve": [NSString stringWithFormat:@"%ld", (long)self.share.curve], | |
444 | @"version": [NSString stringWithFormat:@"%ld", (long)self.share.version], | |
445 | @"wrappedkey": CKKSNilToNSNull([self.share.wrappedTLK base64EncodedStringWithOptions:0]), | |
446 | @"signature": CKKSNilToNSNull([self.share.signature base64EncodedStringWithOptions:0]), | |
447 | @"ckzone": CKKSNilToNSNull(self.zoneID.zoneName), | |
448 | @"ckrecord": CKKSNilToNSNull([self.encodedCKRecord base64EncodedStringWithOptions:0]), | |
449 | }; | |
450 | } | |
451 | ||
452 | + (instancetype)fromDatabaseRow:(NSDictionary<NSString*, CKKSSQLResult*>*)row { | |
453 | CKRecordZoneID* zoneID = [[CKRecordZoneID alloc] initWithZoneName: row[@"ckzone"].asString ownerName:CKCurrentUserDefaultName]; | |
454 | ||
455 | SFEllipticCurve curve = (SFEllipticCurve)row[@"curve"].asNSInteger; // TODO: sanitize | |
456 | SecCKKSTLKShareVersion version = (SecCKKSTLKShareVersion)row[@"version"].asNSInteger; // TODO: sanitize | |
457 | ||
458 | return [[CKKSTLKShareRecord alloc] initForKey:row[@"uuid"].asString | |
459 | senderPeerID:row[@"senderpeerid"].asString | |
460 | recieverPeerID:row[@"recvpeerid"].asString | |
461 | receiverEncPublicKeySPKI:row[@"recvpubenckey"].asBase64DecodedData | |
462 | curve:curve | |
463 | version:version | |
464 | epoch:row[@"epoch"].asNSInteger | |
465 | poisoned:row[@"poisoned"].asNSInteger | |
466 | wrappedKey:row[@"wrappedkey"].asBase64DecodedData | |
467 | signature:row[@"signature"].asBase64DecodedData | |
468 | zoneID:zoneID | |
469 | encodedCKRecord:row[@"ckrecord"].asBase64DecodedData | |
470 | ]; | |
471 | } | |
472 | ||
473 | @end | |
474 | ||
475 | #endif // OCTAGON |