1.struct和class的区别
在面试之前你觉得所有的计算机专业的学生都应该能答的上来,事实是我面的人里有超过三分一没有答上来。
有时我还会顺便问下swfit里的array是什么类型,在大量复制时会不会有性能问题。
2.介绍一下观察者模式
也许有些人已经觉得设计模式有些过时,没有整本读过。就算如此iOS里常用的几个设计模式我觉得总要了解吧。
这里如果说NSNotificationCenter怎么使用的就直接pass。
这个回答应该包括三个部分:首先这个设计模式为了解决什么问题,其次通过什么方案来解决,最后才是当前体系下的具体实现方案。
3.在一个https连接的网站里,输入账号密码点击登录后,到服务器返回这个请求前,中间经历了什么
这题是在其他看到的,本来题目是登录gmail的时候,但是国内也许有些人不知道Google很早就全站https了,所以这里特别指出是https的连接。
这里面可以谈的东西就很多了,TCP/IP下有非常多的协议。不需要什么都能说的清楚,但是对于整个网络连接模型的理解可以看出基本功。
4.在一个app中间有一个button,在你手触摸屏幕点击后,到这个button收到点击事件,中间发生了什么
runloop和响应链需要说的清楚。
有时还会顺便问问UIResponder、UIControl、UIView的关系。
这4个问题只是为了一上来可以快速筛选掉不合适的程序员,毕竟有的人只需要几分钟就知道他不合适了,好节省时间。
1、Struct和Class的区别
C++中的struct对C中的struct进行了扩充,它已经不再只是一个包含不同数据类型的数据结构了,它已经获取了太多的功能。
struct能包含成员函数吗? 能!
struct能继承吗? 能!!
struct能实现多态吗? 能!!!
既然这些它都能实现,那它和class还能有什么区别?
最本质的一个区别就是默认的访问控制:
默认的继承访问权限
struct是public的,class是private的。
你可以写如下的代码:
struct A
{
char a;
};
struct B : A
{
char b;
};
这个时候B是public继承A的。
如果都将上面的struct改成class,那么B是private继承A的。这就是默认的继承访问权限。
所以我们在平时写类继承的时候,通常会这样写:
class B : public A
就是为了指明是public继承,而不是用默认的private继承。
当然,到底默认是public继承还是private继承,取决于子类而不是基类。
我的意思是,struct可以继承class,同样class也可以继承struct,那么默认的继承访问权限是看子类到底是用的struct还是class。如下:
struct A{};class B : A{}; //private继承
struct C : B{}; //public继承
struct作为数据结构的实现体,它默认的数据访问控制是public的,而class作为对象的实现体,它默认的成员变量访问控制是private的
我依旧强调struct是一种数据结构的实现体,虽然它是可以像class一样的用。我依旧将struct里的变量叫数据,class内的变量叫成员,虽然它们并无区别。
到底是用struct还是class,完全看个人的喜好,你可以将程序里所有的class全部替换成struct,它依旧可以很正常的运行。但我给出的最好建议,还是:当你觉得你要做的更像是一种数据结构的话,那么用struct,如果你要做的更像是一种对象的话,那么用class。
当然,我在这里还要强调一点的就是,对于访问控制,应该在程序里明确的指出,而不是依靠默认,这是一个良好的习惯,也让你的代码更具可读性。
说到这里,很多了解的人或许都认为这个话题可以结束了,因为他们知道struct和class的“唯一”区别就是访问控制。很多文献上也确实只提到这一个区别。
但我上面却没有用“唯一”,而是说的“最本质”,那是因为,它们确实还有另一个区别,虽然那个区别我们平时可能很少涉及。
那就是:“class”这个关键字还用于定义模板参数,就像“typename”。但关键字“struct”不用于定义模板参数。这一点在Stanley B.Lippman写的Inside the C++ Object Model有过说明。
问题讨论到这里,基本上应该可以结束了。但有人曾说过,他还发现过其他的“区别”,那么,让我们来看看,这到底是不是又一个区别。还是上面所说的,C++中的struct是对C中的struct的扩充,既然是扩充,那么它就要兼容过去C中struct应有的所有特性。例如你可以这样写:
struct A //定义一个struct
{
char c1;
int n2;
double db3;
};
A a={‘p’, 7, 3.1415926}; //定义时直接赋值
也就是说struct可以在定义的时候用{}赋初值。那么问题来了,class行不行呢?将上面的struct改成class,试试看。报错!噢~于是那人跳出来说,他又找到了一个区别。我们仔细看看,这真的又是一个区别吗?
你试着向上面的struct中加入一个构造函数(或虚函数),你会发现什么?
对,struct也不能用{}赋初值了
的确,以{}的方式来赋初值,只是用一个初始化列表来对数据进行按顺序的初始化,如上面如果写成A a={‘p’,7};则c1,n2被初始化,而db3没有。这样简单的copy操作,只能发生在简单的数据结构上,而不应该放在对象上。加入一个构造函数或是一个虚函数会使struct更体现出一种对象的特性,而使此{}操作不再有效。
事实上,是因为加入这样的函数,使得类的内部结构发生了变化。而加入一个普通的成员函数呢?你会发现{}依旧可用。其实你可以将普通的函数理解成对数据结构的一种算法,这并不打破它数据结构的特性。
那么,看到这里,我们发现即使是struct想用{}来赋初值,它也必须满足很多的约束条件,这些条件实际上就是让struct更体现出一种数据机构而不是类的特性。
那为什么我们在上面仅仅将struct改成class,{}就不能用了呢?
其实问题恰巧是我们之前所讲的——访问控制!你看看,我们忘记了什么?对,将struct改成class的时候,访问控制由public变为private了,那当然就不能用{}来赋初值了。加上一个public,你会发现,class也是能用{}的,和struct毫无区别!!!
做个总结,从上面的区别,我们可以看出,struct更适合看成是一个数据结构的实现体,class更适合看成是一个对象的实现体。
2、观察者模式
什么是观察者模式?我们先打个比方,这就像你订报纸。比如你想知道美国最近放生了些新闻,你可能会订阅一份美国周刊,然后一旦美国有了新的故事,美国周刊就发一刊,并邮寄给你,当你收到这份报刊,然后你就能够了解美国最新的动态。其实这就是观察者模式,A对B的变化感兴趣,就注册为B的观察者,当B发生变化时通知A,告知B发生了变化。这是一种非常典型的观察者的用法,我把这种使用方法叫做经典观察者模式。当然与之相对的还有另外一种观察者模式——广义观察者模式。
从经典的角度看,观察者模式是一种通知变化的模式,一般认为只在对象发生变化感兴趣的场合有用。主题对象知道有观察者存在,设置会维护观察者的一个队列;而从广义的角度看,观察者模式是中传递变化数据的模式,需要查看对象属性时就会使用的一种模式,主题对象不知道观察者的存在,更像是围观者。需要知道主题对象的状态,所以即使在主题对象没有发生改变的时候,观察者也可能会去访问主题对象。换句话说广义观察者模式,是在不同的对象之间传递数据的一种模式。
观察者模式应当是在面向对象编程中被大规模使用的设计模式之一。从方法论的角度出发,传统的认知论认为,世界是由对象组成的,我们通过不停的观察和了解就能够了解对象的本质。整个人类的认知模型就是建立在“观察”这种行为之上的。我们通过不停与世界中的其他对象交互,并观察之来了解这个世界。同样,在程序的世界中,我们构建的每一个实例,也是通过不不停的与其他对象交互(查看其他对象的状态,或者改变其他对象的状态),并通过观察其他实例的变化并作出响应,以来完成功能。这也就是,为什么会把观察模式单独提出来,做一个专门的剖析的原因——在我看来他是很多其他设计模式的基础模式,并且是编程中极其重要的一种设计模式。
经典观察者模式
经典观察者模式被认为是对象的行为模式,又叫发布-订阅(Publish/Subscribe)模式、模型-视图(Model/View)模式、源-监听器(Source/Listener)模式或从属者(Dependents)模式。经典观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个主题对象。这个主题对象在状态上发生变化时,会通知所有观察者对象,使它们能够自动更新自己或者做出相应的一些动作。在文章一开始举的例子就是典型观察者模式的应用。
而在IOS开发中我们可能会接触到的经典观察者模式的实现方式,有这么几种:NSNotificationCenter、KVO、Delegate等
感知通知方式
在经典观察者模式中,因为观察者感知到主题对象变化方式的不同,又分为推模型和拉模型两种方式。
推模型
主题对象向观察者推送主题的详细信息,不管观察者是否需要,推送的信息通常是主题对象的全部或者部分数据。推模型实现了观察者和主题对象的解耦,两者之间没有过度的依赖关系。但是推模型每次都会以广播的方式,向所有观察者发送通知。所有观察者被动的接受通知。当通知的内容过多时,多个观察者同时接收,可能会对网络、内存(有些时候还会涉及IO)有较大影响。
在IOS中典型的推模型实现方式为NSNotificationCenter和KVO。
NSNotificationCenter
NSnotificationCenter是一种典型的有调度中心的观察者模式实现方式。以NSNotificationCenter为中心,观察者往Center中注册对某个主题对象的变化感兴趣,主题对象通过NSNotificationCenter进行变化广播。这种模型就是文章开始发布订阅报纸在OC中的一种类似实现。所有的观察和监听行为都向同一个中心注册,所有对象的变化也都通过同一个中心向外广播。
SNotificationCenter就像一个枢纽一样,处在整个观察者模式的核心位置,调度着消息在观察者和监听者之间传递。
一次完整的观察过程如上图所示。整个过程中,关键的类有这么几个(介绍顺序按照完成顺序):
1、观察者Observer,一般继承自NSObject,通过NSNotificationCenter的addObserver:selector:name:object接口来注册对某一类型通知感兴趣.在注册时候一定要注意,NSNotificationCenter不会对观察者进行引用计数+1的操作,我们在程序中释放观察者的时候,一定要去报从center中将其注销了。
- (void) handleMessage:(NSNotification*)nc{
//解析消息内容
NSDictionary* userInfo = [nc userInfo];
}
- (void) commonInit
{
//注册观察者
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleMessage:) name:kDZTestNotificatonMessage object:nil];
}
2、通知中心NSNotificationCenter,通知的枢纽。
3、主题对象,被观察的对象,通过postNotificationName:object:userInfo:发送某一类型通知,广播改变。
-(void)postMessage
{ [[NSNotificationCenterdefaultCenter]postNotificationName:kDZTestNotificatonMessageobject:NiluserInfo:@{}];
}
4、通知对象NSNotification,当有通知来的时候,Center会调用观察者注册的接口来广播通知,同时传递存储着更改内容的NSNotification对象。
apple版实现的NotificationCenter让我用起来不太爽的几个小问题
在使用NSNotificationCenter的时候,从编程的角度来讲我们往往不止是希望能够做到功能实现,还能希望编码效率和整个工程的可维护性良好。而Apple提供的以NSNotificationCenter为中心的观察者模式实现,在可维护性和效率上存在以下缺点:
1、每个注册的地方需要同时注册一个函数,这将会带来大量的编码工作。仔细分析能够发现,其实我们每个观察者每次注册的函数几乎都是雷同的。这就是种变相的CtrlCV,是典型的丑陋和难维护的代码。
2、每个观察者的回调函数,都需要对主题对象发送来的消息进行解包的操作。从UserInfo中通过KeyValue的方式,将消息解析出来,而后进行操作。试想一下,工程中有100个地方,同时对前面中在响应变化的函数中进行了解包的操作。而后期需求变化需要多传一个内容的时候,将会是一场维护上的灾难。
3、当大规模使用观察者模式的时候,我们往往在dealloc处加上一句:
[[NSNotificationCenter defaultCenter] removeObserver:self]
而在实际使用过程中,会发现该函数的性能是比较低下的。在整个启动过程中,进行了10000次RemoveObserver操作,
@implementation DZMessage
- (void) dealloc
{
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
for(inti=0;i<10000;i++){
DZMessage*message=[DZMessagenew];
}
通过下图可以看出这一过程消耗了23.4%的CPU,说明这一函数的效率还是很低的。
ios desing pattern observer 6
这还是只有一种消息类型的存在下有这样的结果,如果整个NotificationCenter中混杂着多种消息类型,那么恐怕对于性能来说将会是灾难性的。
for (int i = 0 ; i < 10000; i++) {
DZMessage* message = [DZMessage new];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handle) name:[@(i) stringValue] object:nil];
}
增加了多种消息类型之后,RemoveObserver占用了启动过程中63.9%的CPU消耗。
ios desing pattern observer 7
而由于Apple没有提供Center的源码,所以修改这个Center几乎不可能了。
改进版的有中心观察者模式(DZNotificationCenter)
GitHub地址 在设计的时候考虑到以上用起来不爽的地方,进行了优化:
1、将解包到执行函数的操作进行了封装,只需要提供某消息类型的解包block和消息类型对应的protocol,当有消息到达的时候,消息中心会进行统一解包,并直接调用观察者相应的函数。
2、对观察者的维护机制进行优化(还未做完),提升查找和删除观察者的效率。
DZNotificationCenter的用法和NSNotificationCenter在注册和注销观察者的地方是一样的,不一样的地方在于,你在使用的时候需要提供解析消息的block。你可以通过两种方式来提供。
直接注册的方式
[DZDefaultNotificationCenteraddDecodeNotificationBlock:^SEL(NSDictionary*userInfo,NSMutableArray*__autoreleasing*params){
NSString*key=userInfo[@"key"];
if(params!=NULL){
*params=[NSMutableArraynew];
}
[*params addObject:key];
return@selector(handleTestMessageWithKey:);
}forMessage:kDZMessageTest];
实现DZNotificationInitDelegaete协议,当整个工程中大规模使用观察者的时候,建议使用该方式。这样有利于统一管理所有的解析方式。
- (DZDecodeNotificationBlock) decodeNotification:(NSString *)message forCenter:(DZNotificationCenter *)center
{
if (message == kDZMessageTest) {
return ^(NSDictionary* userInfo, NSMutableArray* __autoreleasing* params){
NSString* key = userInfo[@"key"];
if (params != NULL) {
*params = [NSMutableArray new];
}
[*params addObject:key];
return @selector(handlePortMessage:);
};
}
return nil;
}
在使用的过程中为了,能够保证在观察者处能够回调相同的函数,可以实现针对某一消息类型的protocol
@protocolDZTestMessageInterface<NSObject>
-(void)handleTestMessageWithKey:(NSString*)key;
@end
这样就能够保证,在使用观察者的地方不用反复的拼函数名和解析消息内容了。
@interface DZViewController () <DZTestMessageInterface>
@end
@implementation DZViewController
....
- (void) handleTestMessageWithKey:(NSString *)key
{
self.showLabel.text = [NSString stringWithFormat:@"get message with %@", key];
}
KVO
KVO的全称是Key-Value Observer,即键值观察。是一种没有中心枢纽的观察者模式的实现方式。一个主题对象管理所有依赖于它的观察者对象,并且在自身状态发生改变的时候主动通知观察者对象。 让我们先看一个完整的示例:
staticNSString*constkKVOPathKey=@"key";
@implementationDZKVOTest
-(void)setMessage:(DZMessage*)message
{
if(message!=_message){
if(_message){
[_messageremoveObserver:selfforKeyPath:kKVOPathKey];
}
if(message){ [messageaddObserver:selfforKeyPath:kKVOPathKeyoptions:NSKeyValueObservingOptionNewcontext:Nil];
}
_message=message;
}
}
-(void)observeValueForKeyPath:(NSString*)keyPathofObject:(id)objectchange:(NSDictionary*)changecontext:(void*)context
{
if([keyPathisEqual:kKVOPathKey]&&object==_message){
NSLog(@"get
%@",change);
}
}
-(void)postMessage
{
_message.key=@"asdfasd";
}
@end
完成一次完整的改变通知过程,经过以下几次过程:
1、注册观察者[message addObserver:self forKeyPath:kKVOPathKey options:NSKeyValueObservingOptionNew context:Nil];
2、更改主题对象属性的值,即触发发送更改的通知 _message.key = @”asdfasd”;
3、在制定的回调函数中,处理收到的更改通知
- (void) observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
if ([keyPath isEqual:kKVOPathKey] && object == _message) {
NSLog(@"get %@",change);
}
}
4、注销观察者 [_message removeObserver:self forKeyPath:kKVOPathKey];
KVO实现原理
一般情况下对于使用Property的属性,objc会为其自动添加键值观察功能,你只需要写一句@property (noatomic, assign) float age 就能够获得age的键值观察功能。而为了更深入的探讨一下,KVO的实现原理我们先手动实现一下KVO:
@implementationDZKVOManual
-(void)setAge:(int)age
{
[selfwillChangeValueForKey:kKVOPathAge];
if(age!=_age){
_age=age;
}
[selfdidChangeValueForKey:kKVOPathAge];
}
//经验证 会先去调用automaticallyNotifiesObserversForKey:当该函数没有时才会调用automaticallyNotifiesObserversOfAge。这个函数应该是编译器,自动增加的一个函数,使用xcode能够自动提示出来。的确很强大。
//+(BOOL) automaticallyNotifiesObserversOfAge
//{
// return NO;
//}
+(BOOL)automaticallyNotifiesObserversForKey:(NSString*)key
{
if(key==kKVOPathAge){
returnNO;
}
return[superautomaticallyNotifiesObserversForKey:key];
}
@end
首先,需要手动实现属性的 setter 方法,并在设置操作的前后分别调用 willChangeValueForKey: 和 didChangeValueForKey方法,这两个方法用于通知系统该 key 的属性值即将和已经变更了;
其次,要实现类方法 automaticallyNotifiesObserversForKey,并在其中设置对该 key 不自动发送通知(返回 NO 即可)。这里要注意,对其它非手动实现的 key,要转交给 super 来处理。
在这里的手动实现,主要是手动实现了主题对象变更向外广播的过程。后续如何广播到观察者和观察者如何响应我们没有实现,其实这两个过程apple已经封装的很好了,猜测一下的话,应该是主题对象会维护一个观察者的队列,当本身属性发生变动,接受到通知的时候,找到相关属性的观察者队列,依次调用observeValueForKeyPath:(NSString )keyPath ofObject:(id)object change:(NSDictionary )change context:(void *)context来广播更改。 还有一个疑问,就是在自动实现KVO的时候,系统是否和我们手动实现做了同样的事情呢?
自动实现KVO及其原理
我们仔细来观察一下在使用KVO的过程中类DZMessage的一个实例发生了什么变化: 在使用KVO之前:
ios desing pattern observer 3
当调用Setter方法,并打了断点的时候:
ios desing pattern observer 4
神奇的发现类的isa指针发生了变化,我们原本的类叫做DZMessage,而使用KVO后类名变成了NSKVONotifying_DZMessage。这说明objc在运行时对我们的类做了些什么。
我们从Apple的文档Key-Value Observing Implementation Details找到了一些线索。
Automatic key-value observing is implemented using a technique called isa-swizzling. The isa pointer, as the name suggests, points to the object’s class which maintains a dispatch table.This dispatch table essentially contains pointers to the methods the class implements, among other data. When an observer is registered for an attribute of an object the isa pointer of the observed object is modified, pointing to an intermediate class rather than at the true class. As a result the value of the isa pointer does not necessarily reflect the actual class of the instance. You should never rely on the isa pointer to determine class membership. Instead, you should use the class method to determine the class of an object instance.
当某一个类的实例第一次使用KVO的时候,系统就会在运行期间动态的创建该类的一个派生类,该类的命名规则一般是以NSKVONotifying为前缀,以原本的类名为后缀。并且将原型的对象的isa指针指向该派生类。同时在派生类中重载了使用KVO的属性的setter方法,在重载的setter方法中实现真正的通知机制,正如前面我们手动实现KVO一样。这么做是基于设置属性会调用 setter 方法,而通过重写就获得了 KVO 需要的通知机制。当然前提是要通过遵循 KVO 的属性设置方式来变更属性值,如果仅是直接修改属性对应的成员变量,是无法实现 KVO 的。
同时派生类还重写了 class 方法以“欺骗”外部调用者它就是起初的那个类。因此这个对象就成为该派生类的对象了,因而在该对象上对 setter 的调用就会调用重写的 setter,从而激活键值通知机制。此外,派生类还重写了 dealloc 方法来释放资源。
拉模型
ios desing pattern observer 5
拉模型是指主题对象在通知观察者的时候,只传递少量信息或者只是通知变化。如果观察者需求要更具体的信息,由观察者主动从主题对象中拉取数据。相比推模型来说,拉模型更加自由,观察者只要知道有情况发生就好了,至于什么时候获取、获取那些内容、甚至是否获取都可以自主决定。但是,却存在两个问题:
如果某个观察者响应过慢,可能会漏掉之前通知的内容
观察者必须保存一个对目标对象的引用,而且还需要了解主题对象的结构,这就使观察者产生了对主题对象的依赖。
可能每种设计模式都会存在或多或少的一些弊端,但是他们的确能够解决问题,也有更多有用的地方。在使用的时候,就需要我们权衡利弊,做出一个合适的选择。而工程师的价值就体现在,能够在纷繁复杂的工具世界中找到最有效的那个。而如果核桃没被砸开,不是你手力气不大的问题,而是你选错了工具,谁让你非得用门缝夹,不用锤子呢!
当然,上面那段属于题外话。言归正传,在OBJC编程中,典型的一种拉模型的实现是delegate。可能很多人会不同意我的观点,说delegate应当是委托模式。好吧,我不否认,delegate的确是委托模式的一种极度典型的实现方式。但是这并不妨碍,他也是一种观察者模式。其实本来各种设计模式之间就不是泾渭分明的。在使用和解释的时候,只要你能够说得通,而且能够解决问题就好了,没必要纠缠他们的名字。而在通知变化这个事情上delegate的确是能够解决问题的。
我们来看一个使用delegate实现拉模型的观察者的例子:
先实现一个delegate方便注册观察者,和回调函数
@class DZClient;
@protocol DZClientChangedDelegate <NSObject>
- (void) client:(DZClient*)client didChangedContent:(NSString*)key;
@end
@interface DZClient : NSObject
@property (nonatomic, weak) id<DZClientChangedDelegate> delegate;
@property (nonatomic, strong) NSString* key;
@end
注册观察者
//DZAppDelegate
DZClient*client=[DZClientnew];
client.delegate=self;
client.key=@"aa";
当主题对象的属性发生改变的时候,发送内容有变化的通知
@implementation DZClient
- (void) setKey:(NSString *)key
{
if (_key != key) {
_key = key;
if ([_delegate respondsToSelector:@selector(client:didChangedContent:)]) {
[_delegate client:self didChangedContent:@"key"];
}
}
}
观察者收到主题对象有变化的通知后,主动去拉取变化的内容
//DZAppDelegate
-(void)client:(DZClient*)clientdidChangedContent:(NSString*)key
{
if([key isEqual:@"key"]){
NSLog(@"get
changed key %@",client.key);
}
}
广义观察者模式
在上面介绍了,观察者被动的接受主题改变的经典意义上的观察者模式之后,我们再来看一下广义观察者模式。当然上面所讲的经典观察者模式,也是一种一种传递数据的方式。广义观察者涵盖了经典观察者模式。
往往我们会有需要在“观察者”和“主题对象”之间传递变化的数据。而这种情况下,主题对象可能不会像经典观察者模式中的主题对象那样勤劳,在发生改变的时候不停的广播。在广义观察者模式中,主题对象可能是懒惰的,而是由观察者通过不停的查询主题对象的状态,来获知改变的内容。
我们熟悉的服务器CS架构,始终比较典型的冠以观察者模式,服务器是伺服的,等待着客户端的访问,客户端通过访问服务器来获取最新的内容,而不是服务器主动的推送。
之所以,要提出广义观察者模式这样一个概念。是为了探讨一下观察者模式的本质。方便我们能够更深刻的理解观察者模式,并且合理的使用它。而且我们平时更多的将注意力放在了通知变化上面,而观察者根本的目的是在于,在观察者和主题对象之间,传递变化的数据。这些数据可能是变化这个事件本身,也可能是变化的内容,甚至可能是一些其他的内容。
从变化数据传递的角度来思考的话,能够实现这个的模式和策略实在是数不胜数,比如传统的网络CS模型,比如KVC等等。在这里就先不详细展开讨论了。
3、HTTPS连接的前几毫秒发生了什么
提示:英文原文写于2009年,当时的Firefox和最新版的Firefox,界面也有很大改动。以下是正文。
花了数小时阅读了如潮的好评,Bob最终迫不及待为他购买的托斯卡纳全脂牛奶点击了“进行结算”,然后……
哇!刚刚发生了什么?
在点击按钮过后的220毫秒时间内,发生了一系列有趣的事情,火狐浏览器(Firefox)不仅改变了地址栏颜色,而且在浏览器的右下角出现了一个小锁头的标志。在我最喜欢的互联网工具Wireshark的帮助下,我们可以通过一个经过略微调整的用于debug的火狐浏览器来探究这一过程。
根据RFC 2818标准(译者注:RFC 2818为HTTP Over TLS-网络协议),火狐浏览器自动通过连接Amazon.com的443端口来响应HTTPS请求。
很多人会把HTTPS和网景公司(Netscape)于上世纪九十年代中期创建的SSL(安全套接层)联系起来。事实上,随着时间的推移,这两者之间的关系也慢慢淡化。随着网景公司渐渐的失去市场份额,SSL的维护工作移交给了Internet工程任务组(IETF)。由网景公司发布的第一个版本被重新命名为TLS 1.0(安全传输层协议 1.0),并于1999年1月正式发布。考虑到TLS已经发布了将近10年,如今已经很难再见到真正的SSL通信了。
客户端问候(Client Hello)
TLS将全部的通信以不同方式包裹为“记录”(Records)。我们可以看到,从浏览器发出的第一个字节为0x16(十进制的22),它表示了这是一个“握手”记录。
接下来的两个字节是0x0301,它表示了这是一条版本为3.1的记录,同时也向我们表明了TLS1.0实际上是基于SSL3.1构建而来的。
整个握手记录被拆分为数条信息,其中第一条就是我们的客户端问候(Client Hello),即0x01。在客户端问候中,有几个需要着重注意的地方:
随机数:
在客户端问候中,有四个字节以Unix时间格式记录了客户端的协调世界时间(UTC)。协调世界时间是从1970年1月1日开始到当前时刻所经历的秒数。在这个例子中,0x4a2f07ca就是协调世界时间。在他后面有28字节的随机数,在后面的过程中我们会用到这个随机数。
SID(Session ID):
在这里,SID是一个空值(Null)。如果我们在几秒钟之前就登陆过了Amazon.com,我们有可能会恢复之前的会话,从而避免一个完整的握手过程。
密文族(Cipher Suites):
密文族是浏览器所支持的加密算法的清单。整个密文族是由推荐的加密算法“TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA”和33种其他加密算法所组成。别担心其他的加密算法会出现问题,我们一会儿就会发现Amazon也没有使用推荐的加密算法。
Server_name扩展:
通过这种方式,我们能够告诉Amazon.com:浏览器正在试图访问https://www.amazon.com。这确实方便了很多,因为我们的TLS握手时间发生在HTTP通信之前,而HTTP请求会包含一个“Host头”,从而使那些为了节约成本而将数百个网站域名解析到一个IP地址上的网络托管商能够分辨出一个网络请求对应的是哪个网站。传统意义上的SSL同样要求一个网站请求对应一个IP地址,但是Server_name扩展则允许服务器对浏览器的请求授予相对应的证书。如果没有其他的请求,Server_name扩展应该允许浏览器访问这个IPV4地址一周左右的时间。
服务器问候(Server Hello)
Amazon.com回复的握手记录由两个比较大的包组成(2551字节)。记录中包含了0x0301的版本信息,意味着Amazon同意我们使用TLS1.0访问的请求。这条记录包含了三条有趣的子信息:
1、我们得到了服务器的以Unix时间格式记录的UTC和28字节的随机数。
2、32字节的SID,在我们想要重新连接到Amazon.com的时候可以避免一整套握手过程。
3、在我们所提供的34个加密族中,Amazon挑选了“TLS_RSA_WITH_RC4_128_MD5”(0x0004)。这就意味着Amazon会使用RSA公钥加密算法来区分证书签名和交换密钥,通过RC4加密算法来加密数据,利用Md5来校验信息。我们之后会深入的研究这一部分内容。我个人认为,Amazon选择这一密码组是有其自身的原因的。在我们所提供的密码族中,这一加密组的加密方式是CPU占用最低的,这就允许Amazon的每台服务器接受更多的连接。当然了,也许还有一个原因是,Amazon是在向这三种加密算法的发明者Ron Rivest(罗恩·李·维斯特)致敬。
2.证书信息(11):
这段巨大的信息共有2464字节,其证书允许客户端在Amazon服务器上进行认证。这个证书其实并没有什么奇特之处,你能通过浏览器浏览它的大部分内容。
3.服务器问候结束信息(14):
这是一个零字节信息,用于告诉客户端整个“问候”过程已经结束,并且表明服务器不会再向客户端询问证书。
校验证书
此时,浏览器已经知道是否应该信任Amazon.com。在这个例子中,浏览器通过证书确认网站是否受信,它会检查 Amazon.com 的证书,并且确认当前的时间是在“最早时间”2008年8月26日之后,在“最晚时间”2009年8月27日之前。浏览器还会确认证书所携带的公共密钥已被授权用于交换密钥。
为什么我们要信任这个证书?
证书中所包含的签名是一串非常长的大端格式的数字:
任何人都可以向我们发送这些字节,但我们为什么要信任这个签名?为了解释这个问题,我们首先要回顾一些重要的数学知识:
RSA加密算法的基础介绍
人人常常会问,编程和数学之间有什么联系?证书就为数学在编程领域的应用提供了一个实际的例子。Amazon的服务器告诉我们需要使用RSA算法来校验证书签名。什么又是RSA算法呢?RSA算法是由麻省理工(MIT)的Ron Rivest、Adi Shamirh和Len Adleman(RSA命名各取了三人名字中的首字母)三人于上世纪70年代创建的。三位天才的学者结合了2000多年数学史上的精华,发明了这种简洁高效的算法:
选取两个较大的初值p和q,相乘得n;n = p*q 接下来选取一个较小的数作为加密指数e,d作为解密指数是e的倒数。在加密的过程中,n和e是公开信息,解密密钥d则是最高机密。至于p和q,你可以将他们公开,也可以作为机密保管。但是一定要记住,e和d是互为倒数的两个数。
假设你现在有一段信息M(转换成数字),将其加密只需要进行运算:C ≡ Me (mod n)
这个公式表示M的e次幂,mod n表示除以n取余数。当这段密文的接受者知道解密指数d的时候就可以将密文进行还原:Cd ≡ (Me)d ≡ Me*d ≡ M1 ≡ M (mod n)
有趣的是,解密指数d的持有者还可以将信息M进行用解密指数d进行加密:Md ≡ S (mod n)
加密者将S、M、e、n公开之后,任何人都可以获得这段信息的原文:Se ≡ (Md)e ≡ Md*e ≡ Me*d ≡ M1 ≡ M (mod n)
如同RSA的公共密钥加密算法经常被称之为非对称算法,因为加密密钥(在我们的例子中为e)和解密密钥(在我们的例子中是d)并不对称。取余运算的过程也不像我们平常接触的运算(诸如对数运算)那样简单。RSA加密算法的神奇之处在于你可以非常快速的进行数据的加密运算,即 ,但是如果没有解密密码d,你将很难破解出密码,即运算 将不可能实现。正如我们所看到的,通过对n进行因式分解而得到p和q,再推断出解密密钥d的过程难于上青天。
签名验证
在使用RSA加密算法的时候,最重要的一条就是要确保任何涉及到的数字都要足够复杂才能保证不被现有的计算方法所破解。这些数字要多复杂呢?Amazon.com的服务器是利用“VeriSign Class 3 Secure Server CA”来对证书进行签名的。从证书中,我们可以看到这个VeriSign(电子签名校验器,也称威瑞信公司)的系数n有2048位二进制数构成,换算成十进制足足有617位数字:
1890572922 9464742433 9498401781 6528521078 8629616064 3051642608 4317020197 7241822595 6075980039 8371048211 4887504542 4200635317 0422636532 2091550579 0341204005 1169453804 7325464426 0479594122 4167270607 6731441028 3698615569 9947933786 3789783838 5829991518 1037601365 0218058341 7944190228 0926880299 3425241541 4300090021 1055372661 2125414429 9349272172 5333752665 6605550620 5558450610 3253786958 8361121949 2417723618 5199653627 5260212221 0847786057 9342235500 9443918198 9038906234 1550747726 8041766919 1500918876 1961879460 3091993360 6376719337 6644159792 1249204891 7079005527 7689341573 9395596650 5484628101 0469658502 1566385762 0175231997 6268718746 7514321
(如果你想要对这一大串数字进行分解因式获得p和q,那就祝你好运!顺便一提,如果你真的计算出了p和q,那你就破解了Amazon.com数字签名证书了!)
这个VeriSign的加密密钥e是 。当然,他们将解密密钥d保管得十分严密,通常是在拥有视网膜扫描和荷枪实弹的警卫守护的机房当中。在签名之前,VeriSign会根据相关约定的技术文档,对Amazon.com证书上所提供的信息进行校验。一旦证书信息符合相关要求,VeriSign会利用SHA-1哈希算法获取证书的哈希值(hash),并对其进行声明。在Wireshark中,完整的证书信息会显示在“signedCertificate”(已签名证书)中:
这里应该是软件的用词不当,因为这一段实际上是指那些即将被签名的信息,而不是指那些已经包含了签名的信息。
实际上经过签名的信息S,在Wireshark中被称之为“encrypted”(密文)。我们将S的e次幂除以n取余数(即公式: )就能计算出被加密的原文,其十六进制如下:
0001FFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF FFFFFFFF00302130 0906052B0E03021A05000414C19F8786 871775C60EFE0542 E4C2167C830539DB
根据PKCS#1 v1.5标准(译者注:The Public-Key Cryptography Standards (PKCS)是由美国RSA数据安全公司及其合作伙伴制定的一组公钥密码学标准)规定:“第一个字节是00,这样就可以保证加密块在被转换为整数的时候比其加密参数要小。”第二个字节为01,表示了这是一个私有密钥操作(数字签名就是私有密钥操作的一种)。后面紧接着的一连串的FF字节是为了填充数据,使得这一串数字变得足够大(加大黑客恶意破解的难度)。填充数字以一个00字节结束。紧接着的30 21 30 09 06 05 2B 0E 03 02 1A 05 00 04 14这些字节是PKCS#1 v2.1标准中用于说明这段哈希值是通过SHA-1算法计算而出的。最后的20字节是SHA-1算法所计算出来的哈希值,即对未加密信息的摘要描述。(译者注:原文中这里使用了带引号的signedCertificate,根据作者前文描述,这应该是Wireshark软件的bug,实际上应指的是未被加密的信息。)
因为这段信息的格式正确,且最后的哈希值与我们独立计算出来的校验一致,所以我们可以断定,这一定是知道“VeriSign Class 3 Secure Server CA”的解密密钥d的人对它进行了签名。而世界上只有VeriSign公司才知道这串密钥。
当然了,我们也可以重复验证这个“VeriSign Class 3 Secure Server CA”的证书的确是通过VeriSign公司的“第三类公私证书认证(Class 3 Public Primary Certification Authority)”进行签名的。
但是,即便是这样,我们为什么要信任VeriSign公司?整个的信任链条就此断掉了。
由图可以看到,“VeriSign Class 3 Secure Server CA”对Amazon.com进行了签名,而“VeriSign Class 3 Public Primary Certification Authority”对“VeriSign Class 3 Secure Server CA”进行了签名,但是最顶部的“VeriSign Class 3 Public Primary Certification Authority”则对自己进行了签名。这是因为,这个证书自从NSS(网络安全服务)库中的certdata.txt 升级到1.4版之后就作为“受信任的根证书颁发机构”(译者注:参照微软官方翻译)被编译到了Mozilla产品中(火狐浏览器)。这段信息是由网景公司的Robert Relyea于2000年9月6日提交的,并随附以下注释:
“由仅存的NSS编译了框架。包含一个在线的certdata.txt文档,其中包含了我们受信的跟证书颁发机构(一旦我们获得了其他受信机构的许可会陆续将他们添加进去)”。
这个举动有着相当长远的影响,因为这些证书的有效日期是从1996年1月28日到2028年1月1日。
肯·汤普逊(Ken Thompson)在他的《对深信不疑的信任》(译者注:Reflections on Trusting Trust是肯汤普逊1983年获得图灵奖时的演说)的演说中解释的很好:你最终还是要绝对信任某一人,在这个问题上没有第二条路可走。在本文的例子中,我们就毫无保留的信任Robert Relyea做了一个正确的决定。我们同样希望Mozilla在自己软件中加入“受信任根证书颁发机构”这种行为也是合理的吧。
这里需要注意的是:这一系列的证书和签名只是用来形成一个信任链。在公共互联网上,VeriSign的根证书被火狐浏览器完全信任的时间远早于你接触互联网。在一个公司中,你可以创建自己的受信任的根证书颁发机构并把它安装到任何人的计算机中。
相对的,你也可以购买VeriSign公司的业务,降低整个证书信任链的信任风险。通过第三方的认证机构(在这个例子里是VeriSign公司)我们能利用证书建立起信任关系。如果你有类似于“悄悄话”的安全途径来传递一个秘密的key,那你也可以使用一个预共享密钥(PSK)来建立起信任关系。诸如TLS-PSK、或者带有安全远程密码(SRP)的TLS扩展包都能让我们使用预共享密钥。不行的是,这些扩展包在应用和支持方面远远比不上TLS,所以他们有的时候并不实用。另外,这些替代选项需要额外德尔安全途径进行保密信息的传输,这一部分的开销远比我们现在正在应用的TLS庞大。换句话说,这也就是我们为什么不应用那些其他途径构建信任关系的原因。
言归正传,我们所需要的最后确认的信息就是在证书上的主机名跟我们预想的是一样的。Nelson Bolyard在SSL_AuthCertificate 函数中的注释为我们解释其中的原因:
“SSL连接的客户端确认证书正确,并检查证书中所对应的主机名是否正确,因为这是我们应对中间人攻击的唯一方式!” (译者注:中间人攻击是一种“间接”的入侵攻击,这种攻击模式是通过各种技术手段将受入侵者控制的一台计算机虚拟放置在网络连接中的两台通信计算机之间,这台计算机就称为“中间人”。)/* cert is OK. This is the client side of an SSL connection.
* Now check the name field in the cert against the desired hostname.
* NB: This is our only defense against Man-In-The-Middle (MITM) attacks! */
这样的检查是为了防止中间人攻击:因为我们对整个信任链条上的人都采取了完全信任的态度,认为他们并不会进行黑客行为,就像我们的证书中所声称它是来自Amazon.com,但是假如他的真实来源并非Amazon.com,那我们可能就有被攻击的危险。如果攻击者使用域名污染(DNS cache poisoning)等技术对你的DNS服务器进行篡改,那么你也许会把黑客的网站误认为是一个安全的受信网站(诸如Amazon.com),因为地址栏显示的信息一切正常。这最后一步对证书颁发机构的检查就是为了防止这样的事情发生。
随机密码串(Pre-Master Secret)
现在我们已经了解了Amazon.com的各项要求,并且知道了公共解密密钥e和参数n。在通信过程中的任何一方也都知道了这些信息(佐证就是我们通过Wireshark获得了这些信息)。现在我们所需要做的事情就是生成一串窃密者/攻击者都不能知道的随机密码。这并不像听上去的那么简单。早在1996年,研究人员就发现了网景浏览器1.1的伪随机数发生器仅仅利用了三个参数:当天的时间,进程ID和父进程ID。正如研究人员所指出的问题:这些用于生成随机数的参数并不具有随机性,而且他们相对来说比较容易被破解。
因为一切都是来源于这三个随机数参数,所以在1996,利用当时的机器仅需要25秒钟的时间就可以破解一个SSL通信。找到一种生成真正随机数的方法是非常困难的,如果你不相信这一点,那就去问问Debian OpenSSL的维护工程师吧。如果随机数的生成方式遭到破解,那么建立在这之上的一系列安全措施都是毫无意义的。
在Windows操作系统中,用于加密目的随机数都是利用一个叫做CryptGenRandom的函数生成的。这个函数的哈希表位对超过125个来源的数据进行抽样!火狐浏览器利用CryptGenRandom函数和它自身的函数来构成它自己的伪随机数发生器。(译者注:之所以称之为伪随机数是因为真正意义上的随机数算法并不存在,这些函数还是利用大量的时变、量变参数来通过复杂的运算生成相对意义上的随机数,但是这些数之间还是存在统计学规律的,只是想要找到生成随机数的过程并不那么容易)。
我们并不会直接利用生成的这48字节的随机密码串,但是由于很多重要的信息都是由他计算而来的,所以对随机密码串的保密就显得格外重要。正如我之前所预料到的,火狐浏览器对随机密码串的保密十分严格,所以我不得不编译了一个用于debug的版本。为了观察随机密码串,我还特地设置了SSLDEBUGFILE和SSLTRACE两个环境变量。
其中,SSLDEBUGFILE显示的就是随机密码串的值:
4456:SSL[131491792]:Pre-MasterSecret[Len:48]
0301bb7b0898a749dee8e9b89152ec81...{...I.....R..
4cc2397bf6ba1c0ab1955029be02ade6L.9{......P)....
ad6e113f20c466f06422577ee1067a3b.n.?.f.d"W~..z;
需要注意的是,这串数字从各种意义上来说都不是真正的随机数,就拿它的前两位来说:这就是根据TLS协议约定的TLS版本号(0301)。
密码交换(Trading Secret)
我们现在需要做的就是计算出Amazon.com所要求的密码。因为Amazon.com希望使用“TLS_RSA_WITH_RC4_128_MD5”加密组,所以我们使用RSA加密算法进行这一过程。你可以将这48字节的随机密码串作为初始参数,但是根据公共密钥密码标准(PKCS)#1 v1.5中的注释,我们需要用随机数据将随机密码串填充到实际要求的参数大小(1024位二进制/128字节)。这样的话攻击者想要破解我们的随机密码串就难上加难了。这也是我们保障自己安全的最后一道防线,以防我们在前面的步骤中犯了诸如重复使用密码这样的低级错误。如果我们重复使用了随机密码串,由于使用了随机数填充,窃密者在网络中拦截的也会是两个不同的值。
同样的,我们很难直接观察到火狐浏览器中的这一过程,所以我不得不在填充随机数的函数中增加了debug的语句,使我们能够观察这一过程:
wrapperHandle = fopen("plaintextpadding.txt", "a");
fprintf(wrapperHandle, "PLAINTEXT = ");
for(i = 0; i < modulusLen; i++)
{
fprintf(wrapperHandle, "%02X ", block[i]);
}
fprintf(wrapperHandle, "\r\n");
fclose(wrapperHandle);
在这个例子中,完整的填充后的随机密码串为:
00 02 12 A3 EA B1 65 D6 81 6C 13 14 13 62 10 53 23 B3 96 85 FF 24 FA CC 46 11 21 24 A4 81 EA 30 63 95 D4 DC BF 9C CC D0 2E DD 5A A6 41 6A 4E 82 65 7D 70 7D 50 09 17 CD 10 55 97 B9 C1 A1 84 F2 A9 AB EA 7D F4 CC 54 E4 64 6E 3A E5 91 A0 06 00 03 01 BB 7B 08 98 A7 49 DE E8 E9 B8 91 52 EC 81 4C C2 39 7B F6 BA 1C0A B1 95 50 29 BE 02 AD E6 AD 6E 11 3F20 C4 66 F0 64 22 57 7E E1 06 7A 3B
火狐浏览器使用这个值计算出 ,我们可以看到它显示在“客户端交换密钥”(Client Key Exchange)的记录中:
在这个过程的最后,火狐浏览器会发送一个不加密的信息:一条“Change Cipher Spec”记录:
通过这种方式:火狐浏览器要求Amazon.com在后面的通信过程中使用约定的加密方式传输信息。
获得主密钥(Master Secret)
如果我们正确完成了之前的过程,并且各方都获得了48字节(256二进制位)的随机密码串。从Amazon.com的角度来看,这里还有一些信任问题:随机密码串是由客户端生成的,并没有将任何服务器信息或者之前约定的信息加入其中。这一点,我们会通过生成主密钥的方式加以完善。根据协议规范约定,这个的计算过程为:
master_secret=PRF(pre_master_secret,"master
secret",ClientHello.random+ServerHello.random)
pre_master_secret就是我们之前传送的随机密码串,”master secret”是一串ASCII码(例如:6d 61 73 74 65 72……),再连接上在客户端问候和服务器问候(来自Amazon的)的信息。
PRF是在规范中约定的伪随机函数,它将密钥、ASCII码标签、哈希值整合在一起。各有一半的参数分别使用MD5和SHA-1获取哈希值。这是一种十分明智的做法,即使是想要单单破解相对简单MD5和SHA-1也不是那么容易的事情。而且这个函数会将返回值传给自身直至迭代到我们需要的位数。
利用这个函数,我们生成了48字节的主密钥:
4C AF 20 30 8F4C AA C5 66 4A 02 90 F2 AC 10 00 39 DB 1D E0 1F CB E0 E0 9D D7 E6 BE 62 A4 6C 18 06 AD 79 21 DB 82 1D 53 84 DB 35 A7 1F C1 01 19
生成各种密钥
现在,各方面已经有了主密钥,根据协议约定,我们需要利用PRF生成这个会话中所需要的各种密钥,称之为“密钥块”(key block):
key_block = PRF(SecurityParameters.master_secret, "key expansion", SecurityParameters.server_random + SecurityParameters.client_random);
密钥块用于构成以下密钥:
client_write_MAC_secret[SecurityParameters.hash_size]
server_write_MAC_secret[SecurityParameters.hash_size]
client_write_key[SecurityParameters.key_material_length]
server_write_key[SecurityParameters.key_material_length]
client_write_IV[SecurityParameters.IV_size]
server_write_IV[SecurityParameters.IV_size]
因为我们使用了类似于高级加密标准(AES)的密码流代替了分组密码我们就不需要初始化向量(IVs)了。因此我们只需要双方的两个16字节(128二进制位)的消息认证码(Message Authentication Code,MAC),因为MD5的哈希值就是16字节的。此外,双方也需要16字节(128二进制位)的RC4码。所以我们总共需要从密码块获得2*16 + 2*16 = 64字节的数据。
运行PRF,我们能得到以下值:
client_write_MAC_secret = 80 B8 F6 09 51 74 EA DB 29 28 EF 6F 9A B8 81 B0
server_write_MAC_secret = 67 7C 96 7B 70 C5 BC 62 9D 1D 1F 4A A6 79 81 61
client_write_key = 32 13 2C DD 1B 39 36 40 84 4A DE E5 6C 52 46 72
server_write_key = 58 36 C4 0D 8C 7C 74 DA 6D B7 34 0A 91 B6 8F A7
准备加密!
客户端最后一次送出的握手信息是“结束信息”。这条信息保证了没有人篡改握手信息,并且我们已经知晓所必须的密钥。客户端将整个握手过程的全部信息都放入一个名为“handshake_messages”的缓冲区。我们能通过伪随机函数利用主密钥、“client finished”标签、MD5和SHA-1的哈希值生成12字节的“区别数据”(verify_data):
verify_data=PRF(master_secret,"client
finished",MD5(handshake_messages)+SHA-1(handshake_messages))[12]
我们在这个结果前面加上0x14(用于表示结束信息)和00 00 0c(用于表示verify_data 有12字节)。就像以后所有的加密过程一样,我们要在加密之前确保原始数据没有被篡改。因为我们使用的是“TLS_RSA_WITH_RC4_128_MD5”密码组,这就意味着我们需要使用MD5哈希函数。
有些人一听到MD5函数就会嗤之以鼻,因为其自身的确存在一些缺陷。我自己当然也不会推荐这种算法。但是TLS的聪明之处就在于他并不直接使用MD5函数,只是利用哈希值的版本来校验数据。只就意味着我们并未直接应用到MD5(m):
HMAC_MD5(Key, m) = MD5((Key ⊕ opad) ++ MD5((Key ⊕ ipad) ++ m)
(其中,⊕表示的是异或运算)
在实际中:
HMAC_MD5(client_write_MAC_secret, seq_num + TLSCompressed.type + TLSCompressed.version + TLSCompressed.length + TLSCompressed.fragment));
正如你所见,我们在函数中使用了一个根据明文(在这里明文叫做“TLSCompressed”)编号的序号(seq_num)。这个序号的作用就是为了阻止攻击者在数据流中间插入之前被其截获的信息。如果发生了这样的攻击,序号就能清楚的警告我们数据中的异常。同样的,这个序号也能帮助我们发现攻击者从数据流中剔除的数据。
剩下的工作只剩下啊加密这些数据了!
RC4加密
我们之前协商过的密码组是“TLS_RSA_WITH_RC4_128_MD5”。这就意味着我们需要使用RC4(Ron`s code 4)加密规则进行数据流的加密。罗纳德李威斯特(Ron Rivest)开发了这种基于一个256字节的Key产生随机加密效果的算法。这个算法简单到你几分钟就能记住。
首先,RC4生成一个256字节的数组S,并用0-255填充。接下来的工作就是将需要将KEY混合插入进数组中并反复迭代。你可以编写一个状态机,利用它来阐释随机字节。为了产生随机字节,我们需要将S数组打乱,如图所示:
为了加密一个字节,我们将其与随机字节进行异或运算。记住:一位二进制数与1作异或运算会翻转(译者注:即1^1=0;1^0=1)。因为我们利用的是随机数,所以从统计学的角度来讲,有一半的数被翻转。这种随机翻转的现象就是我们加密数据的有效方法。正如你所见,这并不复杂,而且计算速度十分快,我认为这也是Amazon.com选择它的原因之一。
记得我们有“client_write_key”和“server_write_key”吗?这就意味我们需要两个RC4实例:一个用来加密浏览器向服务器传送的数据,一个用来解密服务器向浏览器传送的数据。
“client_write_key”最初的几个字节是7E 20 7A 4D FE FB 78 A7 33 …如果我们对这些字节和未加密的数据头以及版本信息(“14 00 00 0C98 F0 AE CB C4 …”)进行异或运算,我们就能得到在Wireshark中看到的加密信息了:
服务器端做的几乎是相同的事情。它们发送了一个密钥协议的说明和一个包含全部握手过程的结束信息,其中有结束信息的解密版本。因此,这种机制就保证了客户端和服务器能成功的解密信息。
欢迎来到应用层!
现在,从我们点击了按钮之后已经过去了220毫秒,我们终于为应用层做好了准备!现在,我们发送的普通的HTTP数据流会通过TLS层的加密实例进行加密,在服务器的解密实例进行解密。而且TLS会对数据进行哈希校验,以保证数据内容的准确性。
在这个时候,整个的握手过程就结束了。我们的TLS记录内容现在有了23条(0x17)。加密数据以“17 03 01”开头,表示了记录类型和TLS版本,后面紧跟着加密数据的大小和哈希校验值。
加密的数据的明文如下:
GET /gp/cart/view.html/ref=pd_luc_mri HTTP/1.1
Host: www.amazon.com
User-Agent: Mozilla/5.0 (Windows; U; Windows NT 6.0; en-US; rv:1.9.0.10) Gecko/2009060911 Minefield/3.0.10 (.NET CLR 3.5.30729)
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive: 300
Connection: keep-alive
在Wireshark中显示如下:
唯一有趣的地方是序号是按照记录来增长,这条记录是1,下一条就是2。
服务器端利用“server_write_key”做着同样的事情。我们能看到服务器的相应结果,包括程序开头的指示位:
解密后的信息如下:
HTTP/1.1200OK
Date:Wed,10Jun200901:09:30GMT
Server:Server
...
Cneonction:close
Transfer-Encoding:chunked
这就是一个来自Amazon负载平衡服务器的普通HTTP回应:包含了非描述性的服务器信息“Server: Server”和一个拼错了的“Cneonction: close”。
TLS层在应用层的下面,所以软件和服务器能够像正常的HTTP传输那样进行工作,唯一的区别就是传输的数据会被TLS层进行加密。
OpenSSL是一个应用很广的TLS开源库。
整个连接会一直保持,除非有一方提出了“关闭警告(closure alert)”并且关闭了连接。如果我们在连接断开后的短时间内再次提出连接请求,我们可以使用之前使用过的key来进行连接,从而避免一次新的握手过程。(这个要取决于服务器端key的有效时间。)
需要注意的是:应用程序可以发送任何数据,但是HTTPS的特殊之处在于WEB应用的广泛普及。要知道还有非常多的基于TCP/IP并且使用TLS进行数据加密的协议(如FTPS,sSMTP)。使用TLS要比你自己发明一种是数据加密方案便捷的多。况且,你所使用的安全协议一定要足够安全。
…完工!
TLS RFC的文档包含了更多的信息,有需要的朋友们可以自己查阅,我们在这里只是简单的介绍了其中的过程和原理,观察了这220毫秒内发生在火狐浏览器和Amazon服务器之间发生的故事:由Amazon.com基于速度和安全的综合考虑选择的“TLS_RSA_WITH_RC4_128_MD5”密码组在HTTPS连接建立过程中的全部流程。
正如我们所看到的那样,如果有人能对Amazon服务器的参数n进行因式分解得到p和q的话,那他就能破解全部的基于亚马逊证书的安全通信。所以Amazon为这个参数设置了有效期以防止这种事情的发生:
在我们提供的密码族中,有一组密码组“TLS_DHE_RSA_WITH_AES_256_CBC_SHA”使用了Diffie-Hellman密钥交换,并因此能提供良好的前向安全特性。这就意味着如果有人破解了交换密钥的数学运算方式,他们也不能利用这个来破解其他的会话。但是他的一个劣势在于其运算需求更大的数字和更高的运算能力。AES算法在很多密码组中都出现了,它与RC4的不同之处在于它每次使用的是16字节的“块”而RC4使用的是单字节。因为其key最高能到256位二进制位,所以一般认为它比RC4的安全性更高。
在短短的220毫秒的时间里,两个节点通过互联网连接起来,并且利用一系列手段建立起了互信机制,构建了加密算法,进行加密数据的传输。
正是因为如此,我们故事的主人公才能在Amazon上买到他想要的牛奶!
4、点击一个Button究竟发生了什么
很简单的一个问题:“在屏幕上有个按钮,你点下去,之后究竟发生了什么”,就这么个问题如果你没有留心的话可能还真不一定回答的出来。也许你会说,它的控制器会响应它的点击方法。可是,然后呢,它的控制器怎么就知道是去响应这个button的点击方法,而不是另外一个呢。要回答这个问题就涉及到响应者链的(The Responder Chain)的问题了,苹果官网是这么说的:
大致呢是这么个意思,当一个又用户引起的事件产生时,UIKit这个框架会产生一个包含处理该事件信息的一个对象。然后它把这个对象放入活动的app事件队列中,对于触摸事件来说,那个对象就是一系列的被打包成UIEvent的触摸事件。对于动作事件来说,那个对象随着你使用的框架以及你感兴趣的动作的不同而不同。
说的有点绕,但起码前两句看懂了不是,你触摸后就会产生一个对象,然后把他放入了一个队列,然后怎么处理的呢,请继续看下文,我门知道那个由于你触摸而产生的对象被放入了一个队列中,那放入队列中干什么呢,当然是等待处理了,那谁去处理它呢。对了,我觉得应该是那个button去处理,我是点了它的,当然是它去处理了,你点的它你当然知道了,那系统是怎么知道的呢。iOS系统使用触碰测试(hit-testing)这个过程来检查,首先系统会主动地检查这个触摸点是否发生着任何一个与之有关的视图范围内,一旦发现这个触摸点在这个视图范围内,那么就递归地依次检查这个视图对象的所有子视图,这样最终找到的那个视图就是hit-testing过程找出来的视图对象。在iOS系统确定了那个视图对象之后,它就把刚才放入事件队列的那个事件仍给这个视图对象来处理。什么?你还没理解,那就请看图:
假如你点的是View E,寻找过程就下面几步:
首先不管你点的哪,它肯定在View A里面,屏幕就那么大,能跑哪去
View A有两个字视图View B 和View C,所以系统就检查是在他两谁的里面
发现不在View B里面,而是在View C里面,于是开始检查View C的字视图View D和View E
发现触摸点不在View D的范围,而在View E的范围
View E已经没有字视图了,于是View E就这么被找到了
哈哈,到这里,我终于明白了,这么简单啊。然而并没什么卵用!知道又怎么样。继续,上面提到了,iOS为了确定应该由哪个程序去响应事件,它会依次去查找视图里面的每一个子View,那它是调用什么方法去查找的呢,每个View都有这么一个hitTest:withEvent:方法,如果点击的是这个View,那么这个View就把自己返回(如果是子View就把子View返回),作为第一响应者。知道这个后我们就可以搞出点事了,比如我们可以拦截这个方法去做自己的事情,比如产品经理提出这么变态要求:“在屏幕的任何地方滑动要求里面的表页跟着滑动”,那么我们可以这么做,新建一个类继承UITableView然后在里面写上一下内容:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
return self;
}