smooth scroll

使用window.scroll API ,从MDN的文档来看,各浏览器的支持情况还不错。

或者自己通过setTimeout加上ease function实现难度也不大。

React在老版本浏览器或Webview下的支持问题

[JavaScript Environment Requirements

React 16依赖于ES6中的Map和Set特性。如果需要React运行在老版本的不支持ES6的浏览器或Webview下,需要babel-polyfillcore-js的支持。

类似于下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
import 'core-js/es6/map';
import 'core-js/es6/set';

// or
import 'babel-polyfill';

import React from 'react';
import ReactDOM from 'react-dom';

ReactDOM.render(
<h1>Hello, world!</h1>,
document.getElementById('root')
);

需要格外注意的是,不论是import core-js还是import babel-polyfill,都一定要写在第一次import React from 'react'的前面。否则是起不到polyfill React中ES6特性的效果的。

一些老版本Android的坑

  • 在伪元素如:before:after使用动画会导致crash,caniuse的known issues上只是说在safari v6版本和以下会有不支持的情况。
  • 不支持不带前缀的transform
  • 不支持在<line>上的stroke-width属性上使用rem
  • 不支持Element.matches方法

ffmpeg

wiki: https://en.wikipedia.org/wiki/FFmpeg
官网: https://www.ffmpeg.org/

主要包含三个命令行指令:

  • ffmpeg,多媒体转码
  • ffplay,基于SDL和ffmpeg的极简播放器
  • ffprobe,多媒体分析

ffmpeg部分支持参数:

  • -i指定输入文件
  • -t处理时间
  • -ss起始时间
  • -b:a-b:v指定音频、视频的输出码率。
  • -rfps 帧率
  • -s 1920x1080设置帧大小
  • -c:a-c:v设置音频、视频编码器
  • -ac声道数
  • -ar音频采样率

更多

React Router使用History路由时,不识别带‘.’的路径

webpack-dev-server的配置里,增加disableDotRule

1
2
3
4
5
...
historyApiFallback: {
disableDotRule: true
}
...

注,这样做会使xxx.html的形式也重定向到默认的index.html,在多入口的项目下会有问题。

React Hooks和React Hot Loader默认配置相冲突

设置RHL的pureSFC配置为true,详见讨论

1
setConfig({ pureSFC: true })

一个简单的rollup配置样例

最近有一个开发前端录音库(严格来说是改进)的需求,目标是发布到npm管理平台上,在打包库上rollup的发挥要优于webpack。刚好想用用试试,就用了rollup作为打包工具。因为场景比webpack更简单,配置上也比webpack好配很多,基本看看官方文档就可以上手了。

不过,文档里用的babel版本还是6.x,使用新版本babel后,配置文件rollup.config.js.babelrc有些改动,这里列在下面。

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
// rollup.config.js
import resolve from 'rollup-plugin-node-resolve';
import commonjs from 'rollup-plugin-commonjs';
import babel from 'rollup-plugin-babel';
import { terser } from "rollup-plugin-terser";

export default {
input: 'src/index.js',
output: {
file: 'index.js',
format: 'es'
},
plugins: [
resolve(),
commonjs(),
babel({
exclude: 'node_modules/**'
}),
terser()
]
};


// .babelrc
{
presets: [
[
'@babel/env',
{
loose: true,
modules: false
}
]
],
plugins: [
['@babel/proposal-object-rest-spread', { loose: true }]
]
}

–END–

背景

在视听类业务或重交互的业务场景下,有时需要在前端采集用户语音。前端实现录音功能可以使用MediaRecorder,或getUserMedia结合AudioContext。其中,前一种方法的支持度惨不忍睹,使用getUserMedia的方式是较为常用的选择。

现有问题

在实现前端录音上,Recorder.js实现了一个基础可用版,不过它支持的可配置项很少,音频采样率、声道数、采样的比特位数都使用的采集配置的默认值。但在大多场景下,录音文件体积较大,4s的录音可以达到700 ~ 800KB,不利于网络传输,需要录音采集参数可配置,以优化文件体积。

另外,有些场景录制的语音需要交给算法组做语音识别,对语音有特定要求:

  • 采样率16000Hz
  • 单声道
  • 采样位数16bit

这时就需要一个优化的前端录音方案,支持根据输入配置修改音频流数据。

优化

这里将原有录音方案的几个关键代码流程整理如下:

其中:

  • 先调用getUserMedia获取音频流,并初始化一个MediaStreamAudioSourceNode。使用connect连接到ScriptProcessorNode上,并连续触发audioprocess事件。
  • onaudioprocess事件处理函数中,拿到录音数据。根据当前recording的值判断是否写入recBuffers中。recording状态可以通过recordstop方法控制。
  • exportWAV方法会触发导出流程,导出步骤里
    • mergeBuffersrecBuffers数组扁平化
    • interleave将各声道信息数组扁平化
    • encodeWAV为即将生成的音频文件写入音频头
    • 最后floatTo16bitPCM将音频设备采集的元素范围在[0,1]之间的Float32Array,转换成一个元素是16位有符号整数的Float32Array中
  • 最后拿到的Blob类型数据可以本地播放或通过FormData上传服务端使用。
    下面分几方面介绍录音方案优化的设计和实现。

音频头拓展

要支持可拓展的采样率、声道、采样比特数,wav音频头也要动态配置。

WAVE格式是Resource Interchange File Format(RIFF)的一种,其基本块名称是“WAVE”,其中包含两个子块“fmt”和“data”。结构上由WAVE_HEADER、WAVE_FMT、WAVE_DATA、采样数据4个部分组成。可以看到实际上就是在PCM数据前面加了一个文件头。WAVE类型文件整体结构图如下:

其中和采样率、声道、采样位数相关的字段有:

  • NumChannels
  • SampleRate
  • ByteRate,等于SampleRate * BlockAlign
  • BlockAlign,等于ChannelCount * BitsPerSample / 8
  • BitsPerSample

这几个字段根据输入的配置项设置即可实现音频头拓展部分。
另外,需要注意的是其中字段有Big Endian和Little Endian的区分,对应在代码里,通过setUint16setUIint32的最后一个入参决定。如下所示:

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
function encodeWAV(samples) {
const buffer = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buffer);

/* RIFF identifier */
writeString(view, 0, 'RIFF');
/* RIFF chunk length */
view.setUint32(4, 36 + samples.length * 2, true);
/* RIFF type */
writeString(view, 8, 'WAVE');
/* format chunk identifier */
writeString(view, 12, 'fmt ');
/* format chunk length, PCM use 16 */
view.setUint32(16, 16, true);
/* sample format (raw), PCM use 1 */
view.setUint16(20, 1, true);
/* channel count */
view.setUint16(22, numChannels, true);
/* sample rate */
view.setUint32(24, sampleRate, true);
/* byte rate (sample rate * block align) */
view.setUint32(28, sampleRate * numChannels * sampleBit / 8, true);
/* block align (channel count * bytes per sample) */
view.setUint16(32, numChannels * sampleBit / 8, true);
/* bits per sample */
view.setUint16(34, sampleBit, true);
/* data chunk identifier */
writeString(view, 36, 'data');
/* data chunk length */
view.setUint32(40, samples.length * 2, true);

// ...

return view;
}

采样率

通常前端录音的音频采样率是音频设备默认使用的44.1kHz(或48kHz)。开发者需要默认以外的采样率时(比如16kHz),可以在录音数据交给encodeWAV封装前根据新的采样率做重采样。

1
2
3
4
5
6
7
8
9
10
function compress(samples, ratio) {
const length = samples.length / ratio;
const result = new Float32Array(length);

for (let index = 0; index < length; index++) {
result[index] = samples[index * ratio];
}

return result;
}

重采样的原理上,程序根据重采样和原始采用率的比值,间隔采样音频原数据,丢弃掉其他采样点数据,从而模拟采样率的等比例下降。

注:间隔丢弃原数据在重采样率是原采样率的整数倍分之一时(即1、1/2、1/3…)才不会损失用户音色。另外,重采样率比原采样率高时,需要在采样点中间额外插值,这里未实现;

声道数

audioprocess事件中,需要根据配置项中的声道数,从inputBuffer取对应声道数据,一般的处理下,会丢弃多余的声道数据。类似地,在存储声道数据时,也要灵活考虑配置项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
this.node.onaudioprocess = (e) => {
if (!this.recording) return;

const buffer = [];
for (let channel = 0; channel < this.config.numChannels; channel++) {
buffer.push(e.inputBuffer.getChannelData(channel));
}
// ...
};

// ...
function record(inputBuffer) {
for (let channel = 0; channel < numChannels; channel++) {
recBuffers[channel].push(inputBuffer[channel]);
}
recLength += inputBuffer[0].length;
}

在最后导出时,根据声道数判断是否需要interleave的步骤。

1
2
3
4
5
if (numChannels === 2) {
interleaved = interleave(buffers[0], buffers[1]);
} else {
[interleaved] = buffers;
}

采样位数

默认的采样位数是16位,在对音质或位数没有明确要求时,可以转成8位。

PCM16LE格式的采样数据的取值范围是-32768到32767,而PCM8格式的采样数据的取值范围是0到255。因此PCM16LE转换到PCM8需要将-32768到32767的16bit有符号数值转换为0到255的8bit无符号数值。实现上,见下面的对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function floatTo16BitPCM(output, offset, input) {
let initOffset = offset;
for (let i = 0; i < input.length; i++, initOffset += 2) {
const s = Math.max(-1, Math.min(1, input[i]));
output.setInt16(initOffset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
}

function floatTo8bitPCM(output, offset, input) {
let initOffset = offset;
for (let i = 0; i < input.length; i++, initOffset++) {
const s = Math.max(-1, Math.min(1, input[i]));
const val = s < 0 ? s * 0x8000 : s * 0x7FFF;
output.setInt8(initOffset, parseInt(val / 256 + 128, 10), true);
}
}

上方的floatTo16BitPCM是转换音频采样数据到PCM数据的原始方法,下面的floatTo8BitPCM方法中parseInt(val / 256 + 128, 10)做了16位到8位的转换。最后在封装音频数据为Blob类型时,根据采样位数使用不同函数即可。

1
2
3
4
5
6
7
8
9
function encodeWAV(samples) {
// ...

sampleBit === 8
? floatTo8bitPCM(view, 44, samples)
: floatTo16BitPCM(view, 44, samples);

return view;
}

其他

最后,由于前端录音场景下,音频流基本都来自getUserMedia,为了减少模板代码,库里封装了一个static方法,快捷地直接由getUserMedia构造一个recorder对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static async createFromUserMedia(config) {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
mandatory: {
googEchoCancellation: 'false',
googAutoGainControl: 'false',
googNoiseSuppression: 'false',
googHighpassFilter: 'false'
},
optional: []
},
video: false
});
const context = new AudioContext();
return new Recorder(context.createMediaStreamSource(stream, config));
}

使用

在之前提到了需要算法组音频识别的场景下,只需要在构造时指定配置项即可。

1
2
3
4
5
6
7
import Recorder from './audio-recorder';

this.recorder = Recorder.createFromUserMedia({
sampleBit: 16, // 可省略
numChannels: 1,
sampleRate: 16000
});

此时,一个500ms的录音大概15KB,换算下来4s大约120KB,比此前的体积小了很多。在不强调音质的场景下,表现要好许多。

小结

上面的录音方案优化实践主要包含下面几点:

  • WAVE音频头修改
  • 重采样音频数据
  • 丢弃多余的声道数据
  • 转换16位音频数据到8位

源码在这里,欢迎使用与拍砖。

参考

HooksReact v16.7.0-alpha中引入的新特性,目前(2018年10月底)还在讨论之中

关于这次改动,官网里特地表明这不是Breaking Changes,并且向前兼容,大家可以放心地使用。在动机上:

  • 使用Hooks将便于开发者拆分和复用state管理的逻辑(而不是state本身)
  • 使用Hooks将把Class组件中的React生命周期方法抽象成effects,根据需要插入
  • 除了state和生命周期方法,React还将class提供的更多features拆分出来作为额外功能,按需使用

下面将从上面几点分别展开介绍,并给出一些使用须知。

State Hook

根据上面所述,用来拆分和复用state管理逻辑。通常情况下,class组件中的state更新逻辑比较简单。和官网给的例子本质上没什么差别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { useState } from 'react';

function Example() {
// Declare a new state variable, which we'll call "count"
const [count, setCount] = useState(0);

return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}

useState是State Hook提供的API方法,它只需1个入参,表示状态的初始值。返回一个pair:

  • 第一个元素,状态本身,类似this.state.xxx
  • 第二个元素,设置状态方法,类似this.setState({ xxx: 'foo' })

需要注意的是,第二个元素,设置状态的方法不是增量更新,而是直接替换,这点和setState有区别。

在下面的渲染部分,直接使用状态名即可。当然这里只声明了一个需要的状态变量,需要新的状态变量(比如:[fruit, setFruit])时,需要用同样的方法获得返回,像下面这样:

1
2
3
4
// Declare multiple state variables!
const [age, setAge] = useState(42);
const [fruit, setFruit] = useState('banana');
const [todos, setTodos] = useState([{ text: 'Learn Hooks' }]);

可以看到,使用State Hook时,如何拆分state到各useState中是需要考虑的事情。

Effect Hook

Effect Hook用来处理React每次渲染完成后的副作用。它等同于componentDidMount, componentDidUpdate, 再加上componentWillUnmount。副作用分两种,需要cleanup和不需要cleanup的。

不需要Cleanup

通常的副作用包括数据请求、DOM修改等等。这些操作不需要清理占用的资源。使用时类似下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { useState, useEffect } from 'react';

function Example() {
const [count, setCount] = useState(0);

useEffect(() => {
document.title = `You clicked ${count} times`;
});

return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}

使用useEffect方法,传入一个函数作为唯一入参。这样,在每次render完成后(包含第一次render),都会执行这个函数,去完成副作用的部分。

你可能有些疑惑,如果我有某个副作用,只在componentDidMount使用一次,比如获取DOM ref这种呢?另外,每次重新渲染后,如果副作用依赖于当前的状态值,难道还需要写if语句判断状态有没有变化吗?接着,往下看。

userEffect这个方法可以有第二个入参,这个入参是数组类型,表示这个effects所依赖的内部状态。(注意:这个状态必须是上面用useState声明的)只有数组内的状态变化时,React才会去执行第一个入参的函数。

另外,数组为空时,表示函数没有依赖,即只在componentDidMount时执行一次即可。

1
2
3
4
5
6
7
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]); // Only re-run the effect if count changes

useEffect(() => {
console.log('This component has been rendered.');
}, []); // Only re-run at the first rendering

最后,useEffect是异步完成的,即不会block浏览器更新屏幕内容,以节省资源。在一些不常见的场景,如需要测量当前布局时,需要用同步的useLayoutEffect

需要Cleanup

有的副作用以添加事件监听、设置定时器等等的subscription的形式进行,这些在组件销毁后需要释放掉占用的资源,避免内存泄漏。类似你之前在componentWillUnmount里写的逻辑。

React用useEffect表示这个副作用的清除操作。用法类似setTimeout,用返回值作为handler。

1
2
3
4
5
6
7
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
// Specify how to clean up after this effect:
return function cleanup() {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});

在实际运行时,一个Effect在组件re-render时都会被重新销毁再重建,以便于在componentDidUpdate时,也能跟踪到副作用内使用的状态的最新值。上面那段代码可能会遇到下面这样的实际运行情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Mount with { friend: { id: 100 } } props
ChatAPI.subscribeToFriendStatus(100, handleStatusChange); // Run first effect

// Update with { friend: { id: 200 } } props
ChatAPI.unsubscribeFromFriendStatus(100, handleStatusChange); // Clean up previous effect
ChatAPI.subscribeToFriendStatus(200, handleStatusChange); // Run next effect

// Update with { friend: { id: 300 } } props
ChatAPI.unsubscribeFromFriendStatus(200, handleStatusChange); // Clean up previous effect
ChatAPI.subscribeToFriendStatus(300, handleStatusChange); // Run next effect

// Unmount
ChatAPI.unsubscribeFromFriendStatus(300, handleStatusChange); // Clean up last effect

为了避免这样的频繁操作影响性能,可以通过上面介绍的传第二个参数的方式优化性能。官方文档在最后还补充了一句:

In the future, the second argument might get added automatically by a build-time transformation.

一些使用准则

  • 在函数内部的最外层使用,别在块语句内使用,以保证正确的内部状态
  • 只在函数组件和自定义Hooks中使用Hooks API,以保证可读性
  • 这个eslint-plugin能帮助你检查代码风格

为什么会有看起来比较别扭的上面两条规则呢?

useStateuseEffect看到,API本身是没有状态的,并不知道API的返回赋值给了哪个变量名。所以,就像介绍里说的:

React relies on the order in which Hooks are called.

React依赖于Hooks的调用顺序,因此在每次render时,Hooks方法的调用顺序一定要保持一致

(猜测内部用类似数组的结构保存了一个函数组件内的多个Hooks)

从而,所有导致Hooks可能不按一致顺序执行的写法都不建议使用。为了保证Hooks执行顺序所见即所得,又有了第二条准则。

组合 - 自定义Hooks

Hooks除了或多或少基于React提供的Hooks外,只是再普通不过的JavaScript function而已。可以将组件中共用的状态逻辑拆分出来作为自定义Hooks。类似下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { useState, useEffect } from 'react';

function useFriendStatus(friendID) {
const [isOnline, setIsOnline] = useState(null);

function handleStatusChange(status) {
setIsOnline(status.isOnline);
}

useEffect(() => {
ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
};
});

return isOnline;
}

强烈建议用户自定义的Hooks函数也以use开头。在使用时,就像使用正常的函数即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function FriendStatus(props) {
const isOnline = useFriendStatus(props.friend.id);

if (isOnline === null) {
return 'Loading...';
}
return isOnline ? 'Online' : 'Offline';
}

function FriendListItem(props) {
const isOnline = useFriendStatus(props.friend.id);

return (
<li style={{ color: isOnline ? 'green' : 'black' }}>
{props.friend.name}
</li>
);
}

Hooks共用的是状态逻辑,使用同一个自定义Hooks创建的状态是相互隔离的

你可以发挥你的想象力,抽象共用的状态逻辑,使用组合的方式(在函数中组合,React并不建议在class组件中使用mixin)构建新组件,减少组件代码长度。

官网举了个非常简单却普遍的useReducer的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function useReducer(reducer, initialState) {
const [state, setState] = useState(initialState);

function dispatch(action) {
const nextState = reducer(state, action);
setState(nextState);
}

return [state, dispatch];
}

function Todos() {
const [todos, dispatch] = useReducer(todosReducer, []);

function handleAddClick(text) {
dispatch({ type: 'add', text });
}

// ...
}

还有什么Hooks

  • useContext,接受React.createContext作为入参,在每次provider更新后,自动用最新的context重渲染。
  • useReducer,组件状态逻辑很复杂时,代替useState使用
  • useCallback,保存一个和当前状态相关的函数,只有状态变化时,函数才会更新,避免重复创建函数。
    1
    2
    3
    4
    5
    6
    const memoizedCallback = useCallback(
    () => {
    doSomething(a, b);
    },
    [a, b]
    );
  • useMemo,保存一个和当前状态相关的值,只有状态变化时,值才会重新计算。不提供数组代表每次渲染都会更新。
  • useRef,获取DOM ref
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    function TextInputWithFocusButton() {
    const inputEl = useRef(null);
    const onButtonClick = () => {
    // `current` points to the mounted text input element
    inputEl.current.focus();
    };
    return (
    <>
    <input ref={inputEl} type="text" />
    <button onClick={onButtonClick}>Focus the input</button>
    </>
    );
    }
  • useImperativeMethods,暴露当前组件Ref给父组件
  • useMutationEffectuseLayoutEffect,类似useEffect只是同步执行,而且执行时机有区别

更多参考文档介绍

还有问题?

实践

刚好最近在某管理后台需要有用户列表页的展示,除了获取数据的副作用,只有渲染的功能,用Hooks实现起来就很自然,而在原来的范式下,因为一个额外的网络请求,就需要把functional组件转成class,随之而来的又是一系列的模板代码和声明周期函数。

使用Hooks之后的代码像下面这样:

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
// ...

function UserList() {
const [users, setUsers] = useState([]);
async function fetchData() {
const res = await getUsers();
if (res.code === 200) {
setUsers(res.data);
}
}
useEffect(() => {
fetchData();
}, []);

return (
<div className="admin-users">
<h2>用户信息</h2>
<Table
columns={columns}
dataSource={users}
pagination={false}
/>
</div>
);
}

使用原来的模式时,大概像下面这样:

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
class UserList extends Component {
constructor(props) {
super(props);
this.state = {
users: []
};
}

async componentDidMount() {
const res = await getUsers();
if (res.code === 200) {
this.setState({ users: res.data });
}
}

render() {
return (
<div className="admin-users">
<h2>用户信息</h2>
<Table
columns={columns}
dataSource={this.state.users}
pagination={false}
/>
</div>
);
}
}

虽然代码行数类似,但是代码信噪比和可拓展性明显上面更优。

感受与展望

我理解Hooks的目的并不是强行把class组件里的功能硬塞到functional组件里(虽然从用法上确实有这种感觉),推荐使用funcational组件的形式。而是一次新的复用组件逻辑方式的尝试。毕竟组合函数比组合class简单多了(React并不建议mixin)。同时通过上面的简单实践可以发现,使用Hooks之后,少了许多Spaghetti code,看上去清爽了许多,可读性也随着提高。

不过另一方面,Hooks的API初看上去挺美,挺简洁好用,那是因为最开始举例的场景简单,不需要hack。由于使用Hooks就意味着用全盘用function的形式写组件,原来用class写法写的复杂的业务组件,如果都用Hooks的方式写,也需要开发者具有一定的设计模式意识。同时在有些场景(比如上面说的prevState,prevProps)要使用比较反直觉的操作才能完成。期待后面Hooks API不断优化后的结果。

在逐渐使用Hooks的方式写组件后,业务中会有一些共用的Hooks抽象出来,整个项目目录结构也会发生变化,Hooks文件的管理方式还要再实践。期待Hooks能让每个模块代码都能小于200行的一天更早到来😌。

本文来自《图解密码技术》一书

基本概念

  • 信源、信宿、信道
  • 加密、解密、密钥
  • 对称密码、公钥密码、混合密码
  • 单向散列(hash)函数、信息认证
  • 数字签名 / 篡改和否认
  • 伪随机数

信息传递时面临的风险:

  • 窃听 -> 对称、公钥密码
  • 篡改 -> 散列函数、消息认证、数字签名
  • 伪装 -> 消息认证、数字签名
  • 否认 -> 数字签名

有悖常识的几点:

  • 不要使用保密的加密算法
  • 使用低强度密码不如不用
  • 任何密码都有可能被破解
  • 密码只是安全的一部分(社工)

入门

  • 凯撒密码 / 平移 / 暴力破解
  • 简单替换密码 / 替换 / 频率分析
  • Enigma / 加密密码的密码 / 针对每日密钥的破解

对称密码

  • 编码和异或
  • 一次性密码和它的问题
  • 使用对称密码时,我们通常默认密钥配送问题已解决

DES

全称Data Encryption Standard。于1977年在美国发明并使用。目前可以被暴力破解,因此不应再使用了。

  • DES以64bit为一个单位,使用和明文等长的密钥。
  • 密钥每8位包含1位纠错码
  • 基本结构使用Feistel网络
    • 加密/解密步骤以轮为单位,DES有16轮
    • 每轮运算不加密右半侧,同时右半侧比特和该轮密钥通过轮函数得到本轮加密左侧的密钥,和左侧做异或得到左侧密文
    • 每轮加密后,进行左右对调,保证右侧的保密
    • 解密时用相同结构,反向使用子密钥和轮函数即可
    • 轮函数可以任意复杂
  • 差分分析和线性分析衡量分组密码强度

三重DES

由IBM开发,步骤为DES加密 -> DES解密 -> DES加密。密钥长度是原来三倍,即168比特。

  • 三步使用同一密钥,即向下兼容DES
  • 根据1、3步是否使用同一密钥,分为DES-EDE2和DES-EDE3
  • 处理速度慢

AES

全称Advanced Encrytion Standard,用来取代DES。由NIST开于1997年开始募集,将作为国家标准。算法要求开源免费,并在社群里公开评审,最终于2000年确定为Rijndael。

  • 基本结构为SPN结构
  • 明文分组长度固定为128bit、密钥长度可以是128、192、256比特三种
  • 每轮分为SubBytes、ShiftRows、MixColumns、AddRoundKey四步
    • SubBytes,将4字节 * 4字节的矩阵与一个转换矩阵相乘,得到替换后的矩阵
    • ShiftRows,逐行做平移
    • MixColumns,逐列做矩阵运算
    • AddRoundKey,和轮密钥矩阵做对应位上的异或运算
  • 解密时,除了AddRoundKey一步,其余均需要做逆运算
  • 目前还没有针对Rijndael的有效攻击
  • 避免使用DES,选择AES

分组密码的迭代模式

分组密码只能加密固定长度的密码。因此,需要有模式来迭代加密任意长度的明文。与分组密码相对的是流密码,对数据进行连续处理。

ECB

全称为Electronic CodeBook。是最简单直接的分组方式。将明文分组加密后直接得到对应位置的密文。不足的位用特定数据填充

  • 问题很明显,不要使用
  • 相同的明文分组会得到相同的密文分组
  • 攻击者无需破译密码也可通过修改密文操纵明文,比如替换或删除分组

CBC

全称Cipher Block Chaining。和ECB的最大不同在于明文分组加密前会和上一个密文分组做一次异或运算。开头的明文分组会和一个随机序列做XOR。

  • 一个密文分组的损坏会影响关联的两个分组的解密
  • 比特的缺失将会影响整个密文的解密
  • 操纵初始化向量反转某一位比特可以反转第一个密文分组的某一位
  • 填充提示攻击,攻击padding部分
  • SSL/TLS使用CBC模式保证通信机密
  • CTS使用最后一个密文填充不足的明文部分

CFB

全程Cipher FeedBack模式。和CBC模式的区别在密文分组先加密,再和下一个明文做异或运算。实际上明文分组和密文分组间只相差了一个异或运算。很类似一次性密码本的模式。

  • 解密时,需要对密文分组和初始向量做加密操作
  • 重放攻击,替换密文分组为原有分组,可使得解密出的明文为原有明文

OFB

全称Output-FeedBack模式。和CFB很像,区别在于OFB每次做XOR的密钥流仅来自于上一次的密钥,和密文分组无关。因为密钥流可以提前准备好,分组加密过程可以是并行的。

  • 第一次的密钥来自初始化向量
  • 速度快
  • 如果碰巧密钥加密后和加密前一样,那么之后的密钥就会是同一个值

CTR

全称CounTeR。CTR和OFB类似,区别在于它的密钥流来自于累加的计数器。密文分组来自于密钥流和明文分组的XOR运算。

  • 计数器由nonce和序号两部分各8字节组成,nonce是随机生成的,序号是从1累加的。
  • 和OFB一样,加密解密速度快,结构简单
  • CTR的密钥流在选定nonce后就确定了,因此可以以任意顺序并行加密、解密

公钥密码

解决了对称密码的密钥配送问题。

密钥配送问题

  • 事先共享,在现实生活中传送
  • 密钥配送中心,集中式管理用户密钥,用其加密临时的会话密钥
  • 使用Diffie-Hellman密钥交换
  • 使用公钥密码

公钥密码

使用加密密钥(公钥)加密,使用解密密钥(私钥)解密,避免密钥的泄露。

  • 发送者使用加密密钥
  • 接收者使用解密密钥
  • 加密密钥可以公开
  • 解密密钥一定要保密

目前所使用的公钥密码RSA来自于1978年的发明。流程上,

  • 接收者生成公私钥对,发送公钥给发送者
  • 发送者使用公钥加密明文
  • 接收者使用私钥解密密文

公钥密码有两个问题:

  • 认证公钥的合法性
  • 处理速度慢

RSA

利用了数论中求解离散对数困难且耗时的特点。

  • 加密,使用密文=明文 ^ E mod N。E和N组合成公钥。
  • 解密,使用明文=密文 ^ D mod N。D和N组合成密钥。

生成N、E、D和顺序如下:

  1. 寻找互质的两个大数p和q,N为二者的乘积
  2. p-1和q-1的最小公倍数记为L
  3. 寻找比L小的和L互质的数,即为E
  4. 寻找比L小的和E乘积取模L为1的数,即为D

因为解密时有对N取模操作,因此加密的明文不能大于N。

攻击方式

  • 破解密文 -> 求解离散对数很难
  • 暴力破解D -> 比特位太长,很难破解
  • 通过E求解D,只要知道p和q就能算出D -> 不知道p和q的组合 -> 对N质因数分解很难

中间人攻击里,攻击者可以替换掉原本的公钥,发送给接收者,使用自己的私钥解密,从而实现攻击。这时需要证书保证公钥的权威性。

选择密文攻击里,攻击者可以利用服务端返回的错误消息收集加密算法信息。RSA-OAEP会在明文开头加上明文散列值和填充位,解密时发现散列值和内容对不上时,会隐藏错误信息。

除了RSA外,还有ElGamal方式、Robin方式、ECC(椭圆曲线密码)等公钥密码。它们分别利用了mod N下求离散对数,mod N下求平方根,和椭圆曲线上做乘法运算逆运算在数学上很难求解的特点。

FAQ

Q: 和对称密码的强度对比
A: 达到同等强度,RSA大致需要密钥是AES长度的20倍

Q: RSA使用的质数会用完么
A: 512bit的质数数目大约是10 ^ 150。足够使用。

Q: RSA破解难度如何?
A: 和大整数质因数分解一样难度

Q: 要保证RSA强度,N的长度要达到多少位
A: 2048bit,4096bit更好

混合密码系统

  • 用对称密码加密明文
  • 用公钥密码加密上述对称密码的密钥(通常用随机数生成器得到,只用于此次会话)
  • 公钥密码的密钥由外部赋予(证书)

密码软件PGP、HTTPS中使用的SSL/TLS就使用了混合密码系统。当然它们还包含数字签名、认证、私钥管理等更多处理。

类似混合密码系统,后面要介绍的数字签名、证书、消息认证、伪随机数生成也都是基础密码技术的组合

单向散列函数

  • 将任意长度的消息转换到固定长度散列
  • 具有抗碰撞性,即找到具有相同散列函数的消息很困难
  • 单向性,即无法从三列中还原原信息

MD4、MD5

全称Message Digest。由Rivest设计于1990和1991年。能够产生128bit的散列值。它们的强抗碰撞性已被攻破,不建议使用

RIPEMD-160

1996年设计,是欧盟RIPE项目的修订版,能产生160bit长度的散列值。比特币中使用的散列函数就是RIPEMD-160。

SHA

SHA于1993年由NIST设计,在1995年发布了SHA-1修订版,能够产生160bit的散列值。它的强抗碰撞性已被攻破,也不建议使用

SHA-2于2002年发布,它是包括SHA-256,SHA-384和SHA-512的集合,分别产生256、384和512bit的散列值。目前未被攻破。SHA-2的几种散列长度来自SHA-256和SHA-512的组合。

SHA-3作为SHA-1的升级替代算法,和AES一样,由NIST公开选拔,并在2012年确定为一个叫Keccak的算法。之后会和SHA-2并存一段时间。

Keccak

Keccak可以输入任意长度的数据,产生任意长度的散列值。实现上,Keccak采用海绵结构,有吸收挤出两阶段。

  • 吸收阶段,按分组长度r逐段读入消息内容,和内部状态做异或运算,之后和长度为c的内部状态一起交给函数f做“搅拌”。完成一轮处理,输出作为内部状态继读入输入的消息分组。
  • 挤出阶段,内部消息r和c逐段和函数f做运算,一段段输出散列值。

Keccak的双工结构下,输入和输出可以同时进行。Keccak内部状态由5 * 5 * z的一个三维比特数组组成,共有b个bit。Keccak的本质就是实现一个充分搅拌上述数组的函数f。SHA-3中使用的是Keccak-f[1600]函数。其中b就是内部状态的bit数。函数的每一轮包含θ、ρ、π、χ、ι5步。循环轮数为12 + 2 * log2(b / 25)。

  • θ,将不同两个column的各5个bit通过异或运算加起来,再和当前位做异或替换
  • ρ,各比特沿z轴方向进行平移
  • π,对一个slice上的5 * 5个比特做旋转、轮换操作
  • χ,对一个row上的各位做某个逻辑运算
  • ι,用某个轮常数对所有比特做异或运算,避免对称性

Keccak采用的海绵结构和此前各散列算法使用的MD结构(循环执行压缩函数)方法截然不同,这也是它最后成为标准的一个原因。目前还未出现针对Keccak的有效攻击手段。

攻击方式

利用文件的冗余性,构造一大堆和想要内容一样的数据,找到和原内容散列值一样的结果。

  • 原像攻击,给定散列值,找到具有该散列值的任意消息
  • 第二原像攻击,给定消息1,找到和消息1有相同散列值的消息2
  • 生日攻击,攻击散列算法的“强抗碰撞性”(寻找两个具有相同散列值的消息),利用了从有N个元素的集合中依次取并放回M个元素,两次取到同一元素的概率约为根号N的特点。大大减少暴力破解需要的次数。

消息认证

可以同时防止消息的伪装和篡改。消息认证码简称MAC(Message Authentication Code)。可以简单理解成需要密钥参与的单向散列过程。在使用时:

  • 发送者伴随消息发送计算出的MAC
  • 接受者对消息通过共享密钥计算出MAC值,进行对比,一致则表示认证成功
  • 这个密钥不能被中间人获取!

使用消息认证码(MAC)机制的场景有:

  • SWIFT
  • IPSec
  • SSL/TLS

在认证加密时,Encrypt-then-MAC表示对密文计算MAC值,从而能判断密文是由知道明文和密钥的人生成的。除了Encrypt-then-MAC外,还有Encrypt-and-MAC和MAC-then-Encrypt两种方式。

HMAC

HMAC即Hash MAC,是使用单向散列函数构造认证码的方法。分为下面几步:

  1. 在密钥后填充0到长度达到单向散列函数的分组长度
  2. 填充后的密钥和ipad序列做XOR运算,ipad序列是00110110为单位循环的比特序列
  3. 组合在消息头部,并计算出散列值
  4. 填充后的密钥和opad做XOR运算,opad是01011100位单位循环的比特序列
  5. 结果拼在散列值后面
  6. 根据5的结果计算最终的散列值

应对攻击方式

  • 消息认证需要解决重放攻击的问题,即再次发送相同的消息和MAC值。可以在消息中额外带上序号、时间戳,或先发送一个nonce一次性随机数保证相同的消息也会有完全不同的MAC值。
  • 密钥推测攻击,应保证不能根据MAC值推测出双方使用的密钥,必须使用安全、高强度的伪随机数生成器。

另外,消息认证无法解决下面的问题:

  • 向第三方证明,密钥的共享只在通信的双方,无法证明给第三方
  • 同样的,不能防止通信的一方否认消息

数字签名

和公钥密码相反的使用方式:

  • 发布者使用私钥加密消息,私钥保密
  • 使用发布者的公钥可以解密消息,公钥公开

签名有两种方式:对消息签名对消息的散列值签名。它们主要区别在签名的对象不同。基本过程是:

  1. 生成公、私钥对,发送公钥给接收者
  2. 使用私钥加密消息/消息的hash值,得到签名
  3. 发送消息和签名给接收者
  4. 接收者使用公钥解密,对比消息/消息hash值,验证发送者身份

在签名中,密钥只是起着“保证消息发送者的可靠来源目的的”,被复制并不影响它发挥作用。同时,由于不知道私钥,修改消息后无法伪造消息的签名。

实际应用数字签名的地方有很多:

  • 安全信息公告
  • 软件下载
  • 公钥证书,确保公钥的合法来源
  • SSL/TLS,交换公钥的过程

数字签名基于公钥密码,因此数字签名的实现方式因采用的公钥密码而异,如RSA、ElGamal、ECDSA(椭圆曲线密码)。对数字签名的攻击可以基于单向散列函数或是公钥密码。

  • 不要对不清楚来源的数据做数字签名
  • 对消息的散列值函数做数字签名

数字签名无法解决验证签名正确性的公钥被伪造的问题,因为公钥正确性也依赖于数字签名技术。这里需要证书以及公钥基础设施PKI这种社会学的基础设施辅助。

证书

证书即公钥证书,用来验证公钥密码和数字签名的公钥,由认证机构(CA)发布,认证机构可以是政府机关、一般企业或个人。证书的发布过程包括:

  1. 申请人生成一对密钥,并把公钥发送给CA
  2. CA验证申请人身份
  3. 通过验证后,CA使用自己的私钥对公钥施加数字签名并生成证书
  4. 使用申请人证书的使用者通过CA的公钥验证申请人的公钥是否合法
  5. 验证通过后,使用公钥完成公钥密码或数字签名

PKI是为了能够更有效运用公钥制定的一系列规范的总称。PKI组成要素有3个:使用PKI的用户、认证机构、仓库。

  • 用户,分为注册公钥的用户和使用注册公钥的用户
  • CA,包括生成密钥、验证本人身份、验证公钥合法性、作废证书
  • 仓库,是保存证书的数据库

其中认证机构做了以下事情:

  • 生成密钥对,可以由用户或是CA生成,若是CA生成,需要根据规范发送私钥给用户
  • 注册证书,用户根据规范申请证书,认证机构根据业务准则生成符合X.509规范的证书
  • 作废证书,因为私钥丢失等原因需要作废证书时,需要认证机构制作CRL(Certificate Revocation List,证书作废清单),PKI用户总需要从CA获取最新的CRL,以确认自己拿到的公钥证书是否有效。

认证机构的证书认证

认证机构的公钥证书可以由其他的认证机构施加数字签名。这个关系可以嵌套很多层,比如部门认证机构、分公司认证机构、总公司认证机构。一直往上直到根CA,可以对自己的公钥做自签名。

从而,在验证证书合法性上,也会出现从上至下的验证过程。

证书的攻击

对证书的攻击即对数字签名的攻击。

  • 对施加数字签名前的公钥攻击
  • 注册相似人名进行攻击
  • 窃取CA的私钥
  • 伪装成CA发放证书进行攻击,认证机构本身的可信度也很重要
  • 利用发送CRL的时间间隔,窃取了使用者的私钥,当使用者联系CA发布CRL时,有一定的时间间隔
  • 同样利用CRL,使用合法私钥发送消息后,发送CRL作废自己的公钥,否认自己之前发送的消息

不可能在完全不可信的状态下创建出信任关系,除非以已经存在的信任关系为基础。

密钥

  • 密钥长度(DES:56bit,三重DES:112bit或168bit,AES:128、192、256bit

  • 对称密码和公钥密码用于确保机密性,消息认证码和数字签名使用的密码用于认证,防止篡改内容和伪装身份

  • 只使用一次的密钥称为会话密钥,重复使用的密钥称为主密钥

  • 密码学用途的随机数生成器必须为密码学用途专门设计

  • 定期改变会话密钥可以减少密钥泄露的损失

  • 保存密钥时,使用KEK(Key Encrypting Key)方式保存密钥可以减少管理密钥的数目。

Diffie-Hellman密钥交换

Diffie-Hellman密钥交换里,通信的双方通过交换一些可以公开的消息,就能够生成共享的密钥。

  1. 确定一个非常大的质数P,寻找P的生成元(原根)G
  2. 通信双方各自找1个1 ~ P-2的随机数A、B,生成G ^ A mod P与G ^ B mod P,发送给对方
  3. 对方用收到的数字根据自己选的随机数做乘方运算,得到相等的值作为密钥

它同样利用了离散对数问题难以快速求解的特点。这种交换方法可以做中间人攻击,可以用数字签名、证书等方式应对。

基于口令的密码(PBE)

基于口令的密码避免了:记忆CEK -> 记忆KEK -> 记忆KEK的KEK的死循环。使用好记忆的口令配合盐生成CEK。使用过程如下:

  1. 使用随机数生成器生成盐(随机数),加上用户口令,使用单向散列函数得到KEK
  2. 使用KEK加密会话使用的CEK
  3. 保存好盐以及使用KEK加密的会话秘钥
  • 盐的目的是避免字典攻击
  • 口令虽然便于生成,但是强度不高,因此需要格外地小心保管
  • 可以对KEK迭代使用单向散列函数得到最后的KEK(拉伸),这将加大攻击者的攻击负担

生成安全的口令

  • 使用只有自己知道的信息
    • 不包括别人见过的信息
    • 不包括可以很容易推测的信息
  • 不应该重复使用口令,容易受牵连影响
  • 物理保存是可以的,但要注意安全
  • 可以使用口令生成和管理工具(比如1Password)

随机数生成

随机数生成在密码学中很常用:

  • 生成密钥
  • 生成分组密码的初始化向量
  • 生成CTR模式的nonce
  • 生成盐

随机数至少需要具有下面的属性:

  • 随机等概性
  • 无状态,即无法从上一个推测下一个,生成序列无法重现

由于计算机构成的抽象世界是离散的,内部状态有限,不能满足无状态的特点,因此只能称作伪随机数生成器。基于计算机硬件的随机数生成器可以认为是“真”随机数,它通常提前储存在一个随机数池中,在需要的时候直接从池中取用。伪随机数生成器根据随机的种子(seed)通过算法将内部状态转化为最终的随机数。

  • 线性同余法,以当前随机数为内部状态(初始值为种子),(A x Rn + C) mod M,计算下一个值。其中A、C、M都需要事先选好,线性同余法生成的随机数数列具有可预测性,即不需要知道种子也可以推测下随机数值
  • 单向散列函数,利用单向散列函数保护内部状态,以种子为初始值,逐次递加得到新的内部状态,再通过单向散列函数输出为随机数
  • 密码法,类似单向散列函数,使用密钥加密内部状态输出也可以作为随机数,此时保护内部状态的加密算法和密钥
  • ANSI X9.17中,使用AES和三重DES作为密码算法
    1. 初始化内部状态
    2. 使用当前时间生成掩码
    3. 掩码和内部状态做XOR
    4. 加密3的输出,作为随机数输出
    5. 对加密后的输出与掩码做XOR
    6. 加密5的结果作为新的内部状态

PGP介绍

PGP全程Pretty Good Privacy,编写于1990年,具备现代密码软件所需的几乎所有功能。OpenPGP是一对密文和数字签名进行定义的标准规格。

加密和解密

加密时,使用混合密码系统的流程:

  1. 用伪随机数生成会话密钥
  2. 接收者的公钥加密会话密钥
  3. 压缩消息,并使用对称密码加密,密钥为上面生成的会话密钥
  4. 将加密后的密钥和密文拼接在一起
  5. 将4的结果转换为文本数据,即为报文数据

解密时,PGP的私钥通过用户口令加密保存。在收到密文时:

  1. 输入接收者的口令
  2. 求口令的散列值,生成用户解密私钥的秘钥
  3. 解密得到私钥
  4. 将报文数据转换为二进制,并拆解成加密的会话密钥和压缩的密文
  5. 用自己的私钥解密得到会话密钥
  6. 用会话密钥解密密文
  7. 解压缩明文得到原始消息

生成数字签名

同样,生成数字签名时:

  1. 输入接收者的口令
  2. 求口令的散列值,生成用户解密私钥的秘钥
  3. 解密得到私钥
  4. 使用单向散列函数计算消息散列值
  5. 对散列值签名,即使用私钥加密
  6. 拼合签名和消息,进行压缩
  7. (可选)转换二进制为文本数据,即最后的报文数据

类似地,验证时:

  1. 转换为二进制文件,解压缩数据
  2. 分解出签名和消息两部分
  3. 使用公钥解密签名,得到散列值
  4. 使用单向散列函数计算消息散列值,对比3中的散列值
  5. 相等即验证成功

生成数字签名并加密

实际情况下,我们往往需要使用加密算法加密数字签名中的原消息。实现步骤是上两节的组合。即先进行数字签名,再对签名结果加密。

验证过程是相反的,先解密密文得到签名结果,再验证数字签名。

信任网

PGP确认公钥合法性的方法不依赖于认证机构颁发证书,而是采用所有者信任级别构成信任网(也叫信任圈、朋友圈)的方式,让用户自己决定该信任谁。建立信任有三种方式:

  • 通过自己的签名来确认。用户在通过其他方式(比如线下)确认公钥可信任后,对该公钥加上自己的数字签名。由于PGP中,使用者本人的公钥是绝对信任,被施加签名的公钥因此可信任。注意:这并不代表被施加签名的公钥所有者被完全信任
  • 通过自己完全信任的数字签名进行确认。即完全信任某个公钥进行的数字签名,用户可对当前信任的每个公钥所有者设置信任级别,级别为完全信任时,所有者公钥施加签名的公钥也会被信任。
  • 通过有限信任的多个数字签名进行确认。在设置信任级别为有限信任时,有限信任的公钥施加数字签名后,新的公钥才会被信任。

通过上面三种方式,PGP使用者可以构建起自己的信任网,从而根据自己的决定信任某个公钥。

SSL/TLS

TLS是SSL的后续版本,但在大多数情况下,可以统一写成SSL/TLS。SSL/TLS可以承载应用层协议,保证应用层传输的安全性,HTTP就是其中一种。其余SSL/TLS可以承载的应用层协议还包括SMTP、POP3等等。

SSL于1994年在网景公司开发,在1995年发布了SSL3.0版本,后被发现会导致POODLE攻击。TLS是IETF在1999年作为SSL3.1发布。2006年发布TLS1.1,之后又发布了TLS1.2。

HTTPS中SSL/TLS要保证以下三点:

  • 保证消息传输中不被窃听 -> 对称密码加密消息,公钥密码加密对称密码的密钥
  • 保证消息传输中不被篡改 -> 消息认证
  • 保证消息传输双方的合法性 -> 数字签名生成证书

通信过程

下面的流程以TLS1.2为例。TLS协议分为两层:

  • TLS握手协议,位于上层,处理除加密的部分。可以进一步分为:
    • 握手协议,负责在客户端和服务器间协商密码算法和共享密钥
    • 密码规格变更协议,向通信对象传达变更密码方式
    • 警告协议,在发生错误时将错误传达给对方
    • 应用数据协议,将TLS上承载的应用数据传达给通信对象
  • TLS记录协议,位于底层,处理加密的部分。使用了对称密码和消息认证码,但具体的算法和密钥需要通信双方具体协商

TLS记录协议

记录协议负责数据的压缩、加密、数据认证,工作方式如下:

  1. 分割消息为较小的片段,再分段压缩,压缩方式需要协商决定
  2. 对压缩过的消息进行消息认证,加上MAC值。为了避免重放攻击,在计算MAC值时,加上了片段的编号。其中的单向散列函数的算法、使用的密钥都需要协商确定
  3. 把MAC值和压缩过的消息片段组合在一起,使用对称密码加密。迭代模式使用CBC模式,CBC模式的初始化向量通过主密码生成。对称密码的算法、密钥则需要协商决定
  4. 上述经过加密的数据,再加上数据类型、版本号、压缩后的长度,构成最终的报文数据。数据类型就是之前提到的TLS握手协议的4类子协议

握手协议

握手协议负责生成对称密码中的共享密钥以及交换证书。因为握手的整个过程都是明文进行的,因此需要使用公钥密码或是Diffie-Hellman密钥交换。整个握手协议有下面几步:

  1. ClientHello,客户端发送一些信息给服务器,便于协商算法和密钥
  • 可用版本号,即支持的SSL/TLS版本号
  • 客户端生成的随机数,在后面的步骤会用到
  • 会话ID,在需要重新使用以前的会话时用到
  • 客户端可用的密码套件清单
  • 客户端可用的压缩方式清单
  • 当前时间
  1. ServerHello,服务器根据客户端传来的信息,选择合适的算法和密码套件,返回的消息中带有下面几条
  • 使用的版本号
  • 服务端生成的随机数,后面步骤会用到
  • 会话ID,作用同上
  • 使用的密码套件
  • 使用的压缩方式
  • 当前时间
  1. Certificate非匿名通信时,服务器发送自己的证书,以及对服务器证书签名的CA的证书
  2. ServerKeyExchange,当Certificate消息不足时,服务器通过此消息传递额外信息
  3. CertificateRequest,需要进行客户端认证时,服务端发送此消息,并带上服务器能理解的证书类型、CA名称清单。
  4. ServerHelloDone,服务器发送此消息结束服务器的返回
  5. Certificate,作为CertificateRequest的回应,客户端发送自己的证书,交给服务器验证
  6. ClientKeyExchange,密码套件包含RSA时,会发送经过服务器公钥加密的预备主密码;密码套件包含Diffie-Hellman密钥交换时,会发送Diffie-Hellman密钥交换中的公开值。预备主密码(pre-master secret)是客户端生成的随机数,之后会用做生成主密码的种子。根据预备主密码,通信双方计算出相同的主密码。主密码会用做以下用途:
  • 对称密码的密钥
  • 消息认证码的密钥
  • CBC模式中的初始化向量
  1. CertificateVerify,在服务器发送CertificateRequest时,通过此消息发送客户端使用自己私钥签名的主密码和握手协议传输消息的散列值。证明自己是客户端证书的持有人。
  2. ChangeCipherSpec,客户端发送,表示切换密码开始,实际上是密码规格变更协议的一类报文
  3. Finished,握手结束,此时已使用切换后的密码套件来加密发送。
  4. ChangeCipherSpecFinished。来自服务器,作用同上。

通过上面的步骤,双方达成了下面的目标:

  • 客户端获得了服务器的公钥,完成了服务器认证
  • 服务器获得了客户端公钥,完成了客户端认证(如果需要的话)
  • 生成了对称密码的密钥
  • 生成了消息认证码中的共享密钥

密码规格变更协议

用于在一开始从明文通信切换到使用密码套件沟通。

警告协议

用在握手协议异常、消息认证码错误、无法解压数据等异常情况。

应用数据协议

通信对象间传递应用数据。

主密码

主密码根据预备主密码(pre-master secret)或Diffie-Hellman密钥交换的公开值生成。生成的主密码用于生成对称密码的密钥、消息认证码的密钥、CBC模式的初始化向量

对SSL/TLS的攻击

  • SSL/TLS框架性的特点让它不依赖于某个特定的密码技术,因此对特定密码技术的攻击对SSL/TLS本身影响不大
  • 心脏出血漏洞,发现于2014年,利用TLS心跳拓展对请求的数据大小没有检查,可以获取内存中与请求无关的信息。是OpenSSL实现的漏洞。
  • POODLE攻击,利用CBC中的填充提示攻击,发现于2014年SSL3.0中。
  • FREAK攻击,可以在密码套件协商时,利用中间人攻击,强制使用强度很低的RSA Export Suites。从而在加密后,暴力破解明文。
  • 对伪随机数生成器的攻击
  • 利用之前提过的CRL

总结

密码技术因为人类的不完美而必定不会完美。

  • 对称密码,使用相同密钥加密、解密,保证消息机密性。目前主要使用AES。
  • 公钥密码,使用不同密钥加密、解密,作用同上。使用最广泛的是RSA,还有相关的Diffie-Hellman密钥交换
  • 单向散列函数,将消息转为固定长度散列值的技术,保证消息完整性,目前使用SHA2和SHA3(Keccak)
  • 消息认证码,结合单向散列函数和对称密码,保证消息完整性认证消息,但无法防御否认。目前主要使用HMAC
  • 数字签名,结合单向散列函数和公钥秘钥,保证完整性不可否认性认证消息。是公钥证书采用的技术
  • 伪随机数生成器,配合上述技术使用,需要保证不可预测性不可重现性

密码技术从某种角度看是一种压缩技术:

  • 密钥是机密性的压缩
  • 散列值是消息完整性的压缩
  • 认证值时认证的压缩
  • 随机数种子是不可预测性的压缩

比特币

比特币来自于Satoshi Nakamoto(中本聪,化名)的一篇论文,并于2009年开始实际运用。比特币是一种基于P2P网络的支付结算系统。用户通过它进行进行价值转移。

  • 地址,将公钥使用散列函数求散列值得到,地址都以1开头,剩下内容 不包含O,0,1和I。
  • 钱包,即比特币客户端,可以生成密钥对,公钥用于收款,密钥用于付款
  • 区块链,保存了比特币所有交易记录的账簿,若干交易组成一个区块,在区块头有所有交易的散列值,以及上一个区块的散列值,有交易添加时会触发区块头的散列值变化,并链式传递下去
  • 交易,收、付款方各自生成密钥对,付款方创建交易“地址A向地址B转账x BTC”,并用自己的私钥签署数字签名,之后广播至P2P网络中,完成交易。比特币使用的数字签名基于椭圆曲线DSA,方程为x^2 = y^3 + 7
  • 挖矿,向区块链中添加新区块的行为被称为挖矿,第一个挖矿成功的矿工会获得挖矿奖励和区块所有交易的手续费。为了证明自己确实完成了规定工作,矿工需要进行工作量证明(PoW),即生成的区块头中,前一区块头的散列值必须以若干位的0开头,这个工作需要投入大量的计算资源。区块大约每10分钟添加一个,为了避免通货膨胀,所需的0的个数会不断调整。
    • 根据协议规定,挖矿奖励每4年减少一半
    • 当区块链上同时出现分支时,P2P网络会选择计算量大的分支进行工作
  • 比特币的匿名性只限于交易地址

附录:椭圆曲线

  • 椭圆曲线(EC)源自于求椭圆弧长的椭圆积分的反函数。
  • 定义椭圆曲线上的加法运算
  • 椭圆曲线上的离散对数(ECDLP) - 已知点G和点xG,求整数x
  • 有限域上的离散对数,对点进行模运算
  • 椭圆曲线Diffie-Hellman密钥交换
  • 椭圆曲线ElGamal密码
  • 椭圆曲线DSA(ECDSA)

lottie production环境下bug修复总结

现象:
前段时间,用lottie-web做动画的时候,发现在有个别动画在本地测试时可以正常播放,打包上线后会报库代码内的错误

猜测原因:
打包过程中的uglify有损压缩了lottie-web的代码,导致部分特性的bug

修复方式:

  1. 在webpack配置中,为lottie-web专门指定一个chunk
  2. 在optimization中,指定一个lottie的cacheGroup,保证一个专门的chunk
  3. minimizer中uglifyJSConfig指定exclude为lottie的chunk名,避免被uglify
  4. resolve中,指定lottie-web resolve到’../node_modules/lottie-web/build/player/lottie.min.js’,使用压缩过的版本

大致像下面这样:

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
module.exports = {
entry: {
// ...
lottie: ['lottie-web'],
// ...
},
// ...
resolve: {
alias: {
// ...
'lottie-web': path.join(__dirname, '../node_modules/lottie-web/build/player/lottie.min.js')
}
},
// ...
optimization: {
splitChunks: {
cacheGroups: {
lottie: {
chunks: 'initial',
name: 'lottie',
test: 'lottie',
enforce: true
}
}
},
minimizer: [
new UglifyJsPlugin({
exclude: /lottie/,
})
]
}
};

结果:
问题解决。

Android机型下rem适配不准的问题

参考 https://www.jianshu.com/p/14f6ce51a75f

通过比较document.documentElement.style.fontSizewindow.getComputedStyle(document.documentElement)['font-size'],假设前者是a,后者是b,a * a / b计算得到和设计一致的尺寸。

3D旋转效果

利用CSS中的backface-visibility: hidden;属性,实现在transform: rotateY(180deg)时,页面翻转到不可见区域。

egret学习

场景:H5小游戏开发

投放场景:各种小游戏平台,也可以打包为Android、iOS、Windows Phone应用发布,甚至直接web访问H5页面

开发语言:TypeScript

开发方式:

  • 类Java的代码组织方式,MVC分离项目代码,M和C开发体验类似“用JavaScript写Java项目”。在View部分开发体验类似于用canvas API写页面结构
  • 类Android的resource管理方式(定义json文件描述资源组和路径对应),在代码中用API动态分组或逐个load资源
  • 单一入口,流程上在stage加载完成后,load资源(同时给出loading页面),之后执行游戏逻辑
  • 使用dispatchEvent实现组件间的信息交流

项目结构

入口文件为index.html。在其中引入manifest.json。读入所需的库文件后,根据DOM容器的data-*属性确定项目配置,以及项目入口*.ts(一般是Main.ts)。之后打包编译到bin-debug

业务逻辑放在/src下,资源文件放在/resources中,资源文件用类似于Android的形式进行存取管理。

视图

Displayable元素可以添加到容器中显示。包含下面基类。

  • displayableContainer 视图元素容器
    • stage
    • scrollView
    • sprite
  • bitmap
  • bitmapText
  • textField
  • movieClip
  • shape

movieClip表示逐帧动画。生成方法如下:

  1. RES.getRes获取资源
  2. 使用factory方法构造movieClipData
  3. 使用movieClipData构造movieClip

简单动画用tween来实现。

发布

  • egret publish或run build。发布H5,runtime版本
  • 对应平台support工具,如Android、iOS、微信小程序

不过在最新的egret launcher下,项目本身已经提供的发布到原生的快捷入口,参考官方解释

难点

和React如何结合开发?

View层通过canvas、WebGL实现,不适合和React结合。

部署方式如何结合在App里

小游戏可以发布到HTML5平台,之后类似老的webview页面开发方式,部署到离线包平台或在线页面即可。

SSO实现方案

SSO - Single Sign On 单一站点登录。由一个站点的登录状态实现关联网站免登录。

背景

由sso.xxx.com记录用户登录态,其他需要使用同一登录态的网站需要同步该域名下的登录态cookie到自己的独立域名下。

实际场景

一般公司内部的网站或ToC的集团网页间都有SSO控制,任意访问一个清除了所有cookie网页,观察network中开头的302报文即可发现实现SSO过程中的各跳转逻辑。

实际步骤因实现而异:

  1. (转让控制权)访问目标网页,302到SSO的跳转特定页面,如jump.sso.xxx.com
  2. (写入cookie)302回目标网页的特定页面,如sso.mysite.com。该域名CNAME到sso.xxx.com的服务器
  3. (写入cookie)sso.mysite.com写入cookie到自己的同域名下,再次302到目标网页,完成SSO过程

或者

  1. 同上
  2. (写入cookie)jump.sso.xxx.com做cookie的检查确认,通过url的方式写入回调的user session,再302回mysite.com。
  3. (写入cookie)mysite.com的后台对应路由根据URL里的回调写入cookie,302到目标页面

在写入cookie到新域名过程中,可以有不同的实现方式。

原理

第一步302到sso.xxx.com的时候已经可以带上xxx.com的cookie了,但是由于浏览器安全限制,并不能直接set cookie到独立域名下。需要再次302回原始域名,CNAME到sso的服务器,实现set cookie到独立域名。

为了保证安全性,CNAME到sso的sso.mysite.com所传递的参数需要有安全机制保证。如时间戳、秘钥等保证请求的完整性。避免中间人伪造域名下的请求。同时,链接本身也应有时效性,在超过时间范围失效,避免拦截链接,实现钓鱼网站获取sso.xxx.com的登录态。

具体步骤:

  1. 302到jump.sso.xxx.com后,进行权限检查判断域名是否允许同步,匹配SSO的cookie域名下的cookie取交集,得到需要同步的cookie。
  2. 通过以上两步后,302到sso.mysite.com,url中带上cookie和安全相关的参数
  3. 根据安全参数校验、target是否允许同步,决定返回403还是302。
  4. 通过校验后,同步登录态cookie,302到目标网页

清除登录态时,如何做到相关域名的同时清除,还需要额外设计。

当然,如果sso只在内网使用,在jump.sso.xxx.com做完安全验证后,通过url将结果交由sso.mysite.com设置登录态Cookie,要更为简洁。

git branch rename

如果分支在远端也有的话,工作需要分为本地和远端两部分。

  1. 重命名本地分支
  • 如果就在该分支
    1
    git branch -m new-name
  • 如果在其他分支
    1
    git branch -m old-name new-name
  1. 删除原分支,推送新分支
    1
    git push origin :old-name new-name
  2. 重置upstream设置
    1
    git push origin -u new-name

webpack无痛mock方案

使用webpack-api-mocker实现,对比axios-mock-adapter和其余方案有几个优势:

  • mock部分代码和业务代码分离开,让网络请求部分代码(/apis)有清晰的逻辑,不混杂业务无关内容
  • 热更新,保存即生效
  • 本地dev环境无痛切换到production环境,无需修改任何代码
  • 基于webpack-dev-server,和整个项目耦合,无需本地起服务
    本地开发时,配置webpack.dev.config.js,在devServer部分的配置中加入apiMocker即可。
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
const apiMocker = require("webpack-api-mocker");
// ...
devServer: {
// ...
before(app) {
apiMocker(app, path.resolve('./mock/index.js'))
}
}
对应的路径下,写入mock数据和路径即可,可以灵活组织各模块的mock数据:
const proxy = {
'GET /user/info': {
"code": 200,
"message": "success",
"data": {
// your mock data
}
},
'POST /user/update': {
"code": 200,
"message": "success",
"data": null
}
}

module.exports = proxy;

更多使用,参考webpack-api-mocker文档。

webpack配置使用es6语法

如今现代的前端开发早已采用全es6的语法书写,然而webpack的配置文件需要通过node解析执行,一般还使用es5的语法书写。在需要使用importexport,数组、对象解构等最新特性时就很蛋疼。

比如在最近的开发中,使用webpack-api-mocker时,希望拆分不同领域的接口到不同文件,最后通过对象结构的方式聚合在mocker的入口文件中。使用es5的语法就很麻烦。

实际上,让webpack使用babel解析配置文件分两步即可:

  1. yarn add -D babel-register,让webpack能够使用babel-loader转译配置文件
  2. 修改配置文件后缀为,webpack.config.babel.js,webpack会使用.js前的字符串作为loader

之后就可以愉快地使用es6语法写配置文件了。

autoprefixer remove -webkit-box-orient解决方案

autoprefixer是postcss的插件,会根据browser list,删除一些autodated的样式,其中就包括-webkit-box-orient这个用于hack实现多行省略号的CSS样式。

几种方法:

  • 设置autoprefixer,{remove: false},保留autodated的样式规则
  • 添加flexbox 2009老旧浏览器到broswer list中
  • 如下,通过注释临时disable autoprefixer
1
2
/* autoprefixer: ignore next */
-webkit-box-orient: vertical;
0%