IPFS(InterPlanetary File System,星际文件系统)是一个旨在创建持久且分布式存储和共享文件的网络传输协议。自 2014 年开始由 Protocol Labs(协议实验室)在开源社区的帮助下发展,其最初由 Juan Benet 设计,它是一个开源代码项目。IPFS 本质上是一种内容可寻址、版本化、点对点超媒体的分布式存储、传输协议,目标是补充甚至取代过去20年里使用的超文本媒体传输协议(HTTP),在 IPFS 网络中的节点将构成一个分布式文件存储系统,它试图将所有具有相同文件系统的计算机设备连接在一起,希望构建一个更快、更安全、更自由的互联网时代。

架构设计

IPFS 是一个分布式文件系统,它综合了以前的对等系统的成功应用,将成熟的技术连接成一个单一的内聚系统。IPFS 是点对点的,没有节点是特权的,节点将 IPFS 对象存储在本地存储中,节点间彼此连接并传输对象。这些对象表示文件和其它数据结构。IPFS 协议分为一组负责不同功能的子协议,从上到下至少七层子协议:

  • 身份:管理对等节点身份的生成和验证,S/Kademlia 生成;
  • 网络:管理其它对等节点的连接,使用各种传输层协议,是可配置的;
  • 路由:维护定位对等节点和存储对象需要的信息,DSHT(分布式松散哈希表)实现;
  • 交换:数据分发和交换;
  • 对象:内容可寻址的不可篡改、去冗余的对象连接;
  • 文件:版本控制系统;
  • 命名:自我认证的可变名称系统;

这些子系统集成在一起,互相利用各自的属性,从上到下构成完整的 IPFS 协议栈。

身份

节点身份都是由 NodeId 来表示的,NodeId 是通过 S/Kademlia 静态加密难题算法创建的公钥的密码散列(cryptographic hash),节点存储它们的公钥和私钥(用密码进行加密后),用户每次启动的时候都可以自由的生成一个新的身份标识,但是这样会损失积累的网络收益。激励的节点是保持不变得。

每个节点其实就是一个 Node 结构体:

type NodeId Multihash
type Multihash []byte // self-describing cryptographic hash digest
type PublicKey []byte
type PrivateKey []byte // self-describing keys
type Node struct {
    NodeId NodeID
    PubKey PublicKey
    PriKey PrivateKey
}

基于 S/Kademlia 算法生成的节点身份信息:

difficulty = <integer parameter>
n = Node{}
do {
    n.PubKey, n.PrivKey = PKI.genKeyPair()
    n.NodeId = hash(n.PubKey)
    p = count_preceding_zero_bits(hash(n.NodeId))
} while (p < difficulty)

首次连接后,节点交换公钥,并通过 hash(other.PublicKey) 来验证连接节点的 NodeId,如果不匹配,那么连接就中断。

关于 S/Kademlia

感兴趣可以继续阅读 S/Kademlia: A practicable approach towards secure key-based routing

网络

作为一个庞大的分布式系统,IPFS节点需要与网络中数百个其它节点进行通信,可能跨越广域网,因此IPFS网络层的设计具有这些功能:

  • 传输层:IPFS 可以使用任何传输协议,并且最适合WebRTC DataChannels(用于浏览器连接)或 uTP(LEDBAT);
  • 可靠性:如果底层网络不提供可靠性,IPFS 可使用 uTP(LEDBAT)或 SCTP 来提供可靠性;
  • 可连接性:IPFS 还可以使用 ICE NAT 穿墙打洞技术;
  • 完整性:可以使用哈希校验和来检查消息的完整性;
  • 可验证性:可以使用发送者的公钥通过 HMAC 来检查消息的真实性;

IPFS 可以使用任意的网络进行通信,它不一定是在 IP 协议上运行,IPFS 将地址存储为 multiaddr 格式,便于给底层网络使用,multiaddr 提供了一种格式来表示地址及协议,可以封装成一种方便解析的格式:

# an SCTP/IPv4 connection
/ip4/10.20.30.40/sctp/1234/
  
# an SCTP/IPv4 connection proxied over TCP/IPv4
/ip4/5.6.7.8/tcp/5678/ip4/1.2.3.4/sctp/1234/

路由

IPFS 既然是一个分布式的存储系统,那么节点就需要一个路由系统来查找其它的节点地址或者检索指定的资源对象。IPFS 的路由系统是由基于 S/Kademlia 和 Coral 的 DSHT 来实现的,IPFS的对象大小和使用模式与 Coral 和 Mainline 类似,因此 IPFS DHT 根据大小对存储的值进行区分,小的值(等于或小于1KB)直接存储在 DHT 上。对于更大的值,DHT 只存储可以为块提供服务的节点的引用。

DSHT 的接口定义如下:

type IPFSRouting interface {
    FindPeer(node NodeId) // gets a particular peer’s network address
    SetValue(key []bytes, value []bytes) // stores a small metadata value in DHT
    GetValue(key []bytes) // retrieves small metadata value from DHT
    ProvideValue(key Multihash) // announces this node can serve a large value
    FindValuePeers(key Multihash, min int) // gets a number of peers serving a large value
}
type ContentRouting interface {
	Provide(context.Context, *cid.Cid, bool) error
	FindProvidersAsync(context.Context, *cid.Cid, int) <-chan pstore.PeerInfo
}


type PeerRouting interface {
	FindPeer(context.Context, peer.ID) (pstore.PeerInfo, error)
}

type ValueStore interface {
	PutValue(context.Context, string, []byte) error
	GetValue(context.Context, string) ([]byte, error)
	GetValues(c context.Context, k string, count int) ([]RecvdVal, error)
}

type IpfsRouting interface {
	ContentRouting
	PeerRouting
	ValueStore

	Bootstrap(context.Context) error
}

可以看到 IPFS 现有的路由系统中包含以下几种:

  • 节点路由:用来寻找同伴节点信息;
  • 内容路由:用来查找内容的提供者;
  • 数据存取:根据 key 查找存取数据;

不同的用例将调用不同的路由系统(例如,广域网中的 DHT,局域网中的静态 HT)。只要遵循上面定义的接口,就可以自定义路由系统底部的实现。

交换

IPFS 中的数据分发协议--BitSwap。IPFS 把 BitTorrent 进行了创新,叫作 Bitswap,BitSwap 节点必须以块的形式彼此提供请求的值,简单来说,BitSwap 就是:

  • 向其它节点请求自己需要的块;
  • 提供其它节点请求的块;

信用

IPFS 增加了信用和帐单体系来激励节点去分享,用户在 Bitswap 里增加数据会增加信用分,分享得越多信用分越高。如果用户只去检索数据而不存数据,信用分会越来越低,其它节点会在嵌入连接时优先选择信用分高的。这一设计可以解决女巫攻击,信用分不可能靠机器刷去提高,一直刷检索请求,信用分越刷越低。

策略

不同的节点间在进行数据时会采用不同的策略,不过策略都期望能达到以下目标:

  • 最大化节点和整体的交易能力;
  • 防止空载节点和无信誉的交易;
  • 对其他未知的策略有效且有抵抗力;
  • 对值得信赖的节点要宽容;

这种策略的探索是未来的工作方向。一种实践过的方式是计算节点和其它节点间的负债比:

$r = \frac{bytes_sent}{bytes_recv\ +\ 1}$

根据 r 的值,发送到负债节点的概率为:

$P(send\ |\ r) = 1\ -\ \frac{1}{1\ +\ exp(6\ -\ 3r)}$

当节点的债务比率超过现有信用比率的两倍时,这个函数曲线就会迅速下降,也就是节点发送债务节点的概率就会迅速降低。

账本

BitSwap节点有一个账本来记录与其它节点交易的所有记录,这个可以让节点追踪历史记录并能避免被篡改。当激活了一个连接,BitSwap节点就会互换它们的账本信息,如果这些账本信息并不完全相同,分类账本将会重新初始化,那些应计信贷和债务会丢失。恶意节点可能会有意去丢掉这些账本,从而期望清除自己的债务。节点是不太可能在失去了应计信托的情况下还能累积足够的债务去授权认证。伙伴节点可以自由的将其视为不当行为, 然后拒绝交易。

type Ledger struct {
    owner      NodeId
    partner    NodeId
    bytes_sent int
    bytes_recv int
    timestamp  Timestamp
}
  • 节点可以自由保存账本历史,尽管它不是正确操作的必要条件;
  • 只有当前的账本是有用的;
  • 节点也可以根据需要自由地垃圾回收账本,从不太有用的账本开始,比如旧的(对等点可能不再存在)和小的;

详解

BitSwap 节点遵循以下协议:

// Additional state kept
type BitSwap struct {
    ledgers map[NodeId]Ledger // Ledgers known to this node, inc inactive
    active map[NodeId]Peer // currently open connections to other nodes
    need_list []Multihash // checksums of blocks this node needs
    have_list []Multihash // checksums of blocks this node has
}
    
type Peer struct {
    nodeid NodeId
    ledger Ledger // Ledger between the node and this peer
    last_seen Timestamp // timestamp of last received message
    want_list []Multihash // checksums of all blocks wanted by peer includes blocks wanted by peer’s peers
  
// Protocol interface:
interface Peer {
    open (nodeid :NodeId, ledger :Ledger);
    send_want_list (want_list :WantList);
    send_block (block :Block) -> (complete :Bool);
    close (final :Bool);
}

一个节点连接的生命周期大致如下:

  1. Open:节点间发送账本直到节点同意;
  2. Sending:节点交换 want_listsblocks
  3. Close:节点中断连接;
  4. Ignored:如果节点的策略避免发送,则会忽略;

Peer.open(NodeId, Ledger)

当发生连接的时候,节点会用以前连接的账本或者新创建并清零的账本来初始化一个连接。然后,发送一个携带账本的 Open 信息给节点。

接收到一个 Open 信息之后,对等节点可以选择是否接受此连接。根据接收者的账本,如果发送者是一个不可信的代理(传输低于零或者有很大的未偿还的债务),接收者可能会选择忽略这个请求。为了让错误能够有时间改正和阻扰攻击者,忽略请求是通过 ignore_cooldown 超时来概率性实现的。

如果连接成功,接收者用本地账本来初始化一个 Peer 对象以及设置 last_seen 时间戳。然后,它会将接收到的账本与自己的账本进行比较,如果两个账本完全一样,那么这个连接就被 Open ,如果账本并不完全一致,那么此节点会创建一个新的被清零的账本然后重新发送此账本。

Peer.send_want_list(WantList)

这是在打开连接时完成的,在随机周期超时之后,在 want_list 中发生更改之后,以及在接收到新块之后。
当接收到一个 want_list 之后,节点会存储它。然后会检查自己是否拥有任何它想要的块。如果有,会根据上面提到的 BitSwap 策略来将 want_list 所需要的块发送出去。

Peer.send_block(Block)

发送一个块是直接了当的。节点只是传输数据块。当接收到了所有数据的时候,接收者会计算 Multihash 检验值来验证它是否是自己所需数据,然后发送确认信息。

在完成一个正确的块传输之后,接受者会将此块从 need_list 移到 have_list,最后接收者和发送者都会更新它们的账本来反映出已经传输的数据字节数。

如果一个传输验证失败了,发送者要么会出故障要么会攻击接收者,接收者可以选择拒绝后面的交易。注意,BitSwap 是期望能够在一个可靠的传输通道上进行操作的,所以,可能会引起一个对诚实发送者进行了错误惩罚的传输错误是期望在数据发送给 BitSwap 之前能够被捕捉到。

Peer.close(Bool)

传给 close 最后的一个参数,代表 close 连接是否是发送者的意愿。如果参数值为 false,接收者可能会立即重新建立 open 连接,这避免过早的 close 连接。一个对等节点close连接发生在下面两种情况下:

  1. silence_wait 没有接收到来自于对等节点的任何信息(BitSwap 默认使用30秒)已经超时过期,节点会发送 Peer.close(false)。

  2. 在节点退出和 BitSwap 关闭的时候,节点会发送 Peer.close(true).

在接收到一个 close 消息之后,接收者和发送者会断开连接,清除所有存储的状态。账本可能会被保存下来为了以后的便利。

注意:
Non-open 信息在一个不活跃的连接上应该是被忽略的。在发送 send_block 信息时,接收者应该检查这个块,看它是否是自己所需的,并且是否是正确的,如果是,就使用此块。总之,所有无序的信息都会让接收者触发一个 close(false) 信息并且强制性的重初始化此连接。

对象

IPFS 系统中,大部分数据对象都是以 Merkle DAG 的结构存在,这为内容寻址和去重提供了便利,Merkle DAGS 给 IPFS 提供了很多有用的属性:

  1. 内容寻址:所有内容都是被多重hash校验和来唯一识别的,包括links。
  2. 防止篡改:所有的内容都用它的校验和来验证。如果数据被篡改或损坏,IPFS会检测到。
  3. 重复数据:所有拥有相同内容的对象只存储一次。这对于索引对象非常有用,比如 git 的trees 和 commits,或者数据的公共部分。

IPFS 对象结构:

type IPFSLink struct {
    Name string     // name or alias of this link
    Hash Multihash  // cryptographic hash of target
    Size int        // total size of target
}

type IPFSObject struct {
    links []IPFSLink    // array of links
    data []byte         // opaque content data
}

IPFS 的 Merkle DAG 是一种非常灵活的数据存储方式。唯一的要求是对象引用必须是内容地址,并以上面的格式编码。IPFS 授予应用程序对数据字段的完全控制;应用程序可以使用它们选择的任何自定义数据格式,包括 IPFS 可能不理解的格式。单独的内部对象 link 表允许 IPFS 做:

  • 用对象的形式列出所有对象引用:
> ipfs ls /XLZ1625Jjn7SubMDgEyeaynFuR84ginqvzb
     XLYkgq61DYaQ8NhkcqyU7rLcnSa7dSHQ16x 189458 less
     XLHBNmRQ5sJJrdMPuu48pzeyTtRo39tNDR5 19441 script
     XLF4hwVHsVuZ78FZK6fozf8Jj9WEURMbCX4 5286 template
     
     <object multihash> <object size> <link name>
  • 解决字符串路经查找,例如 foo/bar/baz。给出一个对象,IPFS 会解析第一个路经组成部分进行 hash 放入到对象的link表中,再获取路径的第二个组成部分,一直如此重复下去。因此,任何数据格式的字符串路经都可以在 Merkle DAG 中使用。

  • 递归性的解决所有对象引用:

> ipfs refs --recursive \
       /XLZ1625Jjn7SubMDgEyeaynFuR84ginqvzb
     XLLxhdgJcXzLbtsLRL1twCHA2NrURp4H38s
     XLYkgq61DYaQ8NhkcqyU7rLcnSa7dSHQ16x
     XLHBNmRQ5sJJrdMPuu48pzeyTtRo39tNDR5
     XLWVQDqxo9Km9zLyquoC9gAP8CL1gWnHZ7z
     ...

原始数据和公共 link 结构是在 IPFS 上构造任意数据结构的必要组成部分。虽然很容易看到 Git 对象模型是如何套用 DAG 的,但是考虑一下这些其他潜在的数据结构:

  • 键值存储
  • 传统关系型数据
  • 数据三倍存储
  • 文档发布系统
  • 通信平台
  • 加密货币区块

在此基础上开发者还可以完全自定义自己的数据结构。特别是最后一条,IPFS 为所有的区块链准备好了数据存储结构,IPFS 将作为区块链的基础设施存在

文件

IPFS 还定义了一组对象,用于在 Merkle DAG 上建模版本化的文件系统。对象结构使用的是 protobufs 协议的二进制编码,该对象模型与 Git 类似:

  1. block:一个可变大小的数据块;
  2. list:块或其它列表的集合;
  3. tree:块、列表或其他树的集合;
  4. commit:树的版本历史中的一个快照;

Blob

Blob 对象代表一个文件且包含一个可寻址的数据单元,IPFS 文件可以使用 lists 或者 blobs 来表示,IPFS 的 blobs 就像 Git 的 blobs 或者文件系统数据块。

List

List 对象代表着由几个IPFS的blobs连接成的大文件或者重复数据删除文件。Lists包含着有序的blob序列或list对象。从某种程度上而言,IPFS 的 list 就像一个间接块的文件系统。

Tree

Tree 对象与 Git 中相似,一个名字到哈希值映射的目录。哈希值则表示着 blobs、lists、其它的trees,或者commits。

Commit

Commit 对象代表任何对象在版本历史记录中的一个快照。与 Git 中类似,但是它能够表示任何类型的对象,它同样 link 着发起对象。

命名

到目前为止,IPFS 协议栈已经提供了一个点对点的数据块交换系统,能够在节点之间发送 DAG 对象,并且可以发送和检索不可变的对象。IPFS 哈希代表不可变的数据,这意味着它们是不能被更改的,否则会导致哈希值的变更。这是一件好事,因为它鼓励数据的持久性,但我们仍然需要一种方法来找到最新的 IPFS 哈希以表示你的网站。IPFS 通过一种特殊的功能来实现,那就是可变命名,即 IPNS。

IPNS

IPNS(InterPlanetary Name Space) 允许用户使用一个私有密钥来对IPFS哈希附加一个引用,使用一个公共密钥哈希(简称 pubkeyhash)表示你的网站的最新版本。如果用户使用过比特币,可能会对此比较熟悉,一个比特币地址也是一个 pubkeyhash。如果该链接不起作用,不用担心,能够通过更改 pubkeyhash 所指向的内容,而 pubkeyhash 却永远保持不变。这样,网站的更新问题就得到了解决。接下来,只需要保证这些网站的位置是可用可读的,所有问题就解决了。

可读地址

IPFS\IPNS 的地址是一些很长、可读性很差的字符串,不容易记住。所以 IPFS 允许用户使用现有的域名系统(Domain Name System, DNS)来为 IPFS\IPNS 内容提供友好可读的链接。它允许用户通过在域名服务器上将哈希插入 TXT 记录来实现这一点:

# this DNS TXT record
ipfs.benet.ai. TXT "ipfs=XLF2ipQ4jD3U ..."

# behaves as symlink
ln -s /ipns/XLF2ipQ4jD3U /ipns/fs.benet.ai

参考