2019年12月29日日曜日

ipaファイル内のembedded.mobileprovisionから有効期限(ExpirationDate)を読み込む

AdHocやEnterpriseでのリリースでは有効期限があるので、それを取り出し、ユーザーに見せるようにしていた。

embedded.mobileprovisionをNSDataで読み込み、NSStringに全変換してから、NSStringのメソッドで処理していたのだが、暗号化された文字コード以外の値が入っているため、SDK13ではクラッシュを招いてしまった。

反省して、NSPropertyListSerializationで処理する正しい方法に改めた。



-(NSString *)readExpiredDay
{
    NSString *path = [[NSBundle mainBundle] pathForResource:@"embedded" ofType:@"mobileprovision"];
    if (!path) return @"NG";
    NSData *data = [NSData dataWithContentsOfFile:path];
    
    // plistを抽出
    NSRange start = [data rangeOfData:[@"<?xml" dataUsingEncoding:NSUTF8StringEncoding] options:NSDataSearchBackwards range:NSMakeRange(0, [data length])];
    NSRange end = [data rangeOfData:[@"</plist>" dataUsingEncoding:NSUTF8StringEncoding] options:NSDataSearchBackwards range:NSMakeRange(start.location, [data length] - start.location)];
    NSData *body = [data subdataWithRange:NSMakeRange(start.location, end.location + end.length - start.location)];
    
    // NSDictionaryに変換
    NSPropertyListFormat format;
    NSDictionary *dic = (NSDictionary *)[NSPropertyListSerialization propertyListWithData:body options:NSPropertyListMutableContainersAndLeaves format:&format error:nil];
    if (!dic) return @"NG";
    
    // ExpirationDateを検出
    NSDate *date = dic[@"ExpirationDate"];
    if (!date) return @"NG";

    // UTC->JST変換し文字列とする
    NSDateFormatter *fmt = [[NSDateFormatter alloc] init];
    [fmt setDateFormat:@"yyyy/MM/dd"];
    [fmt setTimeZone:[NSTimeZone defaultTimeZone]];
    NSString *str = [fmt stringFromDate:date];
    
    if (!str) return @"NG";
    return str;
}

swift版のM.Ike様のコードを参考にしました。


[Xcode11 SDK13] UISplitViewControllerでメニューが引っ込まない

Xcode6(SDK8)で非推奨になったのに、放っておいたら、「戻る」ボタンは表示されないし、メニューとして使っているプライマリビューは隠れないし、たいへんなことになってしまった。

willHideViewController:withBarButtonItem:forPopoverController:
のforPopoverControllerでメニューのオブジェクトを取得し、メニューを隠すのに使っていたのだが、SDK13からforPopoverControllerは何も返さなくなってしまった。

修正点は以下の2点。

1. メニューを隠す


縦画面の時だけ隠したいので、端末の向きを、

// NotificationCenter登録


[[UIDevice currentDevice] beginGeneratingDeviceOrientationNotifications];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(changedOrientation:) name:UIDeviceOrientationDidChangeNotification object:nil];

// ハンドラ


- (void)changedOrientation:(NSNotificationCenter*)center {
    switch ([UIDevice currentDevice].orientation) {
        case UIDeviceOrientationLandscapeLeft:
        case UIDeviceOrientationLandscapeRight:
        case UIDeviceOrientationPortrait:
        case UIDeviceOrientationPortraitUpsideDown:
            _orientation = [UIDevice currentDevice].orientation;
            _splitViewController.preferredDisplayMode = UISplitViewControllerDisplayModeAutomatic;
            break;
        default: //FaceUp及びFaceDownでは何もしない。
            break;
    }
}

で_orientationを取得した上で、今までどおり、ゆっくり隠したいので0.3秒を指定。

// メニューを押した後に、以下を実装


switch (_orientation) {
    case UIDeviceOrientationPortrait:
    case UIDeviceOrientationPortraitUpsideDown: {
        [UIView animateWithDuration:0.3 animations:^{
        _splitViewController.preferredDisplayMode = UISplitViewControllerDisplayModePrimaryHidden; }];
        break; }
    default:
        break;
}

2. 「戻る」ボタンを表示する


以下のようなものをviewDidLoadに実装するだけで、あとは自動的にやってくれる

navigationItem.leftBarButtonItem = splitViewController.displayModeButtonItem;

はずだったのだが、起動時の一発目に、左上の文字がでないことがあった。
白紙から作り直せばうまくいくのだが、そういう訳にもいかないので、viewDidAppearに以下を実装。

navigationItem.leftBarButtonItem.title = @"●●●";






2019年11月28日木曜日

[swift4] iOSアプリで暗号化したMQTT通信(subscribe)をやりたい

まずPodfileです。


platform :ios, '11.0'
target 'YourProject' do
  # Comment the next line if you're not using Swift and don't want to use dynamic frameworks
  use_frameworks!
  # Pods for YourProject
  pod 'Moscapsule', :git => 'https://github.com/flightonary/Moscapsule.git', :branch => 'swift4'
  pod 'OpenSSL-Universal'
end

次に実装を示します。

import Moscapsule した上で、以下のようにFirstViewController等に記述して下さい。

{
    ...

    private var _timerTimer?
    private var mqttConfigMQTTConfig!
    private var mqttClientMQTTClient!
    
    // MARK: - オーバーライド
    override func viewDidLoad() {
        super.viewDidLoad()
        //MQTTコールバック登録
        moscapsule_init()
        mqttConfig = MQTTConfig(clientId: NSUUID().uuidString, host: "ホスト名", port: ポート番号, keepAlive: 60)
        mqttConfig.mqttAuthOpts = MQTTAuthOpts(username: "", password: "")
        mqttConfig.cleanSession = true
        if let path = Bundle.main.path(forResource: "ファイル名", ofType: "crt") {
            mqttConfig.mqttServerCert = MQTTServerCert(cafile: path, capath: nil)
        }
        mqttConfig.onMessageCallback = { mqttMessage in
            if mqttMessage.topic == "トピック" {
                if let msg = mqttMessage.payloadString {
                    DispatchQueue.main.async { self.onMQTT(msg: msg) }
                }
            }
        }
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        //MQTT接続、一定時間毎にsubscribe発行したいため、タイマーを用いる
        wakeupTimer()
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        mqttClient.unsubscribe("トピック", requestCompletion: nil)
        sleepTimer()
    }
        
    // MARK: - MQTT発行、受信
    func wakeupTimer() {
        mqttClient = MQTT.newConnection(mqttConfig)
        _timer = Timer.scheduledTimer(timeInterval: 10, target: self, selector: #selector(onTimer), userInfo: nil, repeats: true)
        _timer?.fire()
    }

    func sleepTimer() {
        mqttClient.disconnect()
        _timer?.invalidate()
    }

    @objc func onTimer() {
        if mqttClient.isConnected {
            mqttClient.subscribe("トピック", qos: 0)
        else {
            DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: {
                self._timer?.fire()
            })
            print("1秒タイマー動作")
        }
    }
    
    func onMQTT(msg m: String) {
        mqttClient.unsubscribe("トピック", requestCompletion: nil)
        let ary: [String] = m.components(separatedBy: "}")
        for item in ary {
            let data: [String] = item.components(separatedBy: ",")
            if (data.count > 個数) {
                お好きなように!
            }
        }
    }
}

1. 概要

すでにサーバーでbroker(mosquitto)が動作していることが前提となります。
そのbrokerに対して、一定時間(ここでは10秒)ごとに、subscribeし、onMQTTで文字列を受け取ります。
通信が途切れた場合は、1秒後に再接続を試みます。

2. セキュリティ

平文を避けるため、brokerではTLSを動作させるものとします。
たぶんmosquittoの仕様だと思うのですが、TLSでアクセスするには、サーバー証明書が要るようです。
moscapsuleを使った本実装例は、コマンドラインからの
 mosquitto_sub -h ホスト -p ポート -t トピック --cafile ファイル
の実行と、等価なはずです。

3. さらなるセキュリティ

やってないですが、mqttClientCertで、クライアント証明書を指定すればいいようです。
mosquitto_subのオプション--certと--keyを使うことと、等価なはずです。

4. 受信文字列

文字列はカンマ区切りの羅列を想定しています。
JSONを使うのが一般的なようですが、オレオレサーバー<=>アプリ間なら、単なるカンマ区切りがおすすめです。

5. タイマー

別にタイマーである必要はないのですが、iOSアプリでMQTTを受信して何かさせたいとなると、こうする以外ないような気がします。
アプリがバックグラウンドに隠れたり、アプリ内でタブを切り替えたりすることに備えて、AppDelegateに以下の実装も加えて下さい。

    func applicationDidEnterBackground(_ application: UIApplication) {
        デリゲート.sleepTimer()
    }
    func applicationWillEnterForeground(_ application: UIApplication) {
        デリゲート.wakeupTimer()

    }

6. 謝辞

@fuku2014さんの記事を参考にさせていただきました。
https://qiita.com/fuku2014/items/2b8f76c6e60bc9ff14db

なお、brokerの立て方、証明書の作り方等は、以下に詳しいです。
https://ficus-forum.myvnc.com/t/mosquitto-tls-ssl/154
https://qiita.com/udai1532/items/c0f58e73f76900a8469f

2019年11月26日火曜日

[swift4] クライアント証明書が埋めこまれたiOSアプリだけに、httpsリクエストを許す


自分で立てたサーバーに、信頼できるiOSアプリだけにアクセスさせたい。
こんな時にどうするか。

登場する技術要素としては、
1. HTTPS
2. サーバー証明書
3. クライアント認証
があるわけですが、

1.は言わずとしれた暗号化になります。
これは他人の通信を垣間見ることはできなくなる、というだけの話であり、HTTPSで暗号化したから「とにかく大丈夫」という話にはなりません。

よ〜く考えていただきたいのですが、暗号化されたのは電話で言うところの「音声」だけであり、盗聴防止にはなっていますが、そこの電話番号にガンガンいたずら電話をかけることはできるのです。
無言電話を何万回もかけるいたずらもできるし、オレオレ詐欺みたいに家庭の事情をいろいろ聞き出す電話もできます。

電話がかかってくるのは仕方がないが、知らない電話番号には出ない方法はあって、これが3.になります。
クライアント証明書をもっていないiOSアプリからのアクセスについては、サーバーは拒否することができます。

ここではすでにサーバー側が仕上がっているとして、サーバーから払い出したクライアント証明書をiOSアプリに仕込む方法を記述します。

@yosshi_0511さんの記事を参考にさせていただきました。
https://qiita.com/yosshi_0511/items/b0a584166f9ab0dbb94f



ここではエッセンスだけをswift 4で示します。

func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Swift.Void) {
    switch (challenge.protectionSpace.authenticationMethod) {
    case NSURLAuthenticationMethodServerTrust:
        if let trust = challenge.protectionSpace.serverTrust {
            let credential = URLCredential(trust: trust)
            completionHandler(.useCredential, credential)
            print("Serverはなんでも信じる")
            return
        }
    case NSURLAuthenticationMethodClientCertificate:
        if let path = Bundle.main.path(forResource: "ファイル名", ofType: "pfx") {
            if let data = NSData(contentsOf: URL(fileURLWithPath: path)) {
                var items: CFArray?
                let options = [kSecImportExportPassphrase: "パスコード"]
                if SecPKCS12Import(data, options as CFDictionary, &items) == errSecSuccess {
                    if let cfarr = items {
                        if let certEntry = (cfarr as Array).first as? [String: AnyObject] {
                            let identityRef = certEntry[kSecImportItemIdentity as String] as! SecIdentity
                            let credential = URLCredential(identity: identityRef, certificates: nil, persistence: .none)
                            completionHandler(.useCredential, credential)
                            print("Client認証成功")
                            return
                        }
                    }
                }
            }
        }
    default:
        print("その他")
    }
    completionHandler(.cancelAuthenticationChallenge, nil)
    print("キャンセル")
}

これで主題は達成できるのですが、「Serverはなんでも信じる」が気になると思います。
これが冒頭の2.の話です。
あやしいサーバーは排除したい場合は、ここでサーバー証明書のチェックを行います。
フィッシング詐欺サイト等、なりすましサーバーに対する対策となります。

が、自作のiOSアプリは、あらかじめ仕込んだサーバーだけにアクセスするはずなので、このチェックは「普通」は省略できるはずです。
むろん、アクセス先をユーザーが自由に書き換えることができる仕様なら、チェックは必要と思います。