参考文献:
- [GO96] Goldreich O, Ostrovsky R. Software protection and simulation on oblivious RAMs[J]. Journal of the ACM (JACM), 1996, 43(3): 431-473.
- [Batcher68] Batcher K E. Sorting networks and their applications[C]//Proceedings of the April 30–May 2, 1968, spring joint computer conference. 1968: 307-314.
- [AKS83] Ajtai M, Komlós J, Szemerédi E. An 0 (n log n) sorting network[C]//Proceedings of the fifteenth annual ACM symposium on Theory of computing. 1983: 1-9.
- 从RAM到ORAM - 知乎 (zhihu.com)
文章目录
- Oblivious RAM
- ITM Model
- Oblivious Simulation
- The Square Root Solution
- The Hierarchical Solution
- Simple Case
- General Case
- Oblivious Hash
Oblivious RAM
Oblivious Random-Access Machine (ORAM) 是一种计算机模型,可以抵御(主动/被动)敌手观察到 “访存模式”。所谓 Oblivious 指的就是敌手无法区分不同的访存地址序列,只要这两个输入下程序的执行时间相同。ORAM 最初是用来做软件保护的,但之后在 MPC 等其他领域中大展拳脚(类似于 ZKP 的命运)。
应用场景:假设数据在内存中是加密的,敌手无法观察到内存中写入的数据是什么。但是,假如敌手可以观察到 CPU 的访存地址,那么就会泄露一定的信息(比如,相邻地址的数据被读/写了、某个地址的数据被访问了两次,等等)。
一个最简单的 ORAM 方案:假设在 RAM 中的程序,CPU 共执行 t t t 次访存,内存 MEM 包含 m m m 个 words,那么我们每次访存都:按顺序读取(即使 CPU 只写)并且写入(即使 CPU 只读)每一个字(即使这个 word 不读也不写),共计花费 t ⋅ m t\cdot m t⋅m 次访存。这样,自然地掩盖了访存模式,因为所有的包含 t t t 次访存的序列,都是以完全相同的 t ⋅ m t \cdot m t⋅m 次访存来实现的。
当然上述解决方案太愚蠢了(恐怖直立猿儿时的扳手指数数,就是个人形 ORAM)。一般地,访存次数 t t t 都远小于内存大小 m m m,因此上述方案的 m m m 倍减速是不可承受的。Goldreich 等人在 [GO96] 中给出了更高效的解决方案:
ITM Model
我们将 RAM 分解为两部分:CPU 以及 MEM,并将两者建模为 Interactive Turing Machine(ITM)。所谓 ITM 是一种 message-driven 的多带图灵机,按 rounds 对 “只读输入带、只写输出带、读写工作带、只读通信带、只写通信带” 进行一定的操作。简记 I T M ( c , w ) ITM(c,w) ITM(c,w) 表示工作带长度为 w w w(内存/寄存器的大小)、通信带长度为 c c c(每一轮的消息长度)的交互式图灵机。
这里的 ( i , a , v ) ∈ { 0 , 1 } 2 + k + O ( k ) (i,a,v) \in \{0,1\}^{2+k+O(k)} (i,a,v)∈{0,1}2+k+O(k) 是由 CPU 发出的访存指令。
一般地,我们认为敌手可以观察 MEM 的各个 Cell 是否发生读写(观察通信带),但是敌手无法观察 CPU 内的 Register 是否发生读写。现在我们可以给出 (确定性)RAM 的定义,它包括一对 ITM 以及它们的交互:
但是对于 ORAM 的实现来说,必须要考虑随机性,比如 Random Oracle 或者 PRF。我们需要定义可以访问 Oracle 尤其是 RO 的 RAM 模型。在复杂度分析时,我们认为询问 Orcale 是 “free” 的。 下面给出 Probabilistic RAMs 的定义:
下面我们定义 memory access pattern 以及 Oblivious RAMs。访存模式是一个地址序列 ( a 1 , ⋯ , a t ) (a_1,\cdots,a_t) (a1,⋯,at),其中 a i a_i ai 指定了 CPU 访问 MEM 时所请求的内存地址。
所谓的 “不经意”,就是说 RAM 执行过程中的访存模式不会泄露(前提是序列的长度本身是相同的),即敌手不可区分 CPU 访问 MEM 的指令中的内存地址序列。
Oblivious Simulation
现在,我们试图用 ORAM 来模拟任意一个 RAM,使得运行在 RAM 上的程序转变为运行在 ORAM 上的安全程序。基本要求是两者的函数性相同,安全性要求是后者应当是 Oblivious 执行的。另外,如果原始程序中,两个不同输入的运行时间(确定性的)相同,那么转换之后这两个输入的运行时间(随机变量的分布)也应该相同。
用 Probabilistic-RAM 不经意模拟 (确定性)RAM,定义如下:
对于 Orcale-RAM,可以类似的定义。注意区分两个神谕:原始 Orcale-RAM 的神谕、Probabilistic-RAM 使用到的 Random Oracle。
假设存在函数 g : N → N g:\mathbb N \to \mathbb N g:N→N,对于任意的输入 y y y,若 R A M ( y ) RAM(y) RAM(y) 的运行时间为 T T T,不经意转换后 R A M ′ ( y ) RAM'(y) RAM′(y) 的运行时间至多为 g ( T ) ⋅ T g(T)\cdot T g(T)⋅T,那么我们就说 overhead of oblivious simulations 是函数 g g g(开销即减速倍率)
有时候,我们要求 CPU 知道它在时刻 j j j 之前访问地址 a a a 的 MEM Cell 的次数。这有助于抵御 tampering adversary 把 Cell 的内容替换为旧内容。我们说模拟是 Time-labeled 的,如果存在一个函数 Q ( j , a ) Q(j,a) Q(j,a) 正确计数了时刻 j j j 之前形如 ( s t o r e , a , ⋅ ) (store,a,\cdot) (store,a,⋅) 的指令数。
软件保护的任务,可以归约到用 ORAM 模拟 RAM 的任务上。[GO96] 给出了两种 ORAM 的构造方法:平方根算法、层级算法。
The Square Root Solution
正如上面所说的,只要我每次都读写全部的内存单元,就没有人可以区分出我的访存模式!但是这导致了 m m m 倍的减速,为了降低损失,我们可以仿照 Cache 的思路,假如访问的数据就落在 Cache 内,我们只对 Cache 的内容做完全的扫描,就可以实现 Oblivious 的访存了。
对于一个 R A M m RAM_m RAMm,平方根算法使用一个 O R A M m + 2 m ORAM_{m+2\sqrt m} ORAMm+2m 来模拟它。不经意的两个问题:如何隐藏访存序列的次序(shuffle the memory)、如何隐藏访问该地址的次数(be accessed at most once),使用随机置换 π \pi π 来解决以上问题。
以示区分,我们将原始 RAM 的内存单元称为 virtual address/word,而将 ORAM 的内存单元称为 actual address/word。我们将 O R A M m + 2 m ORAM_{m+2\sqrt m} ORAMm+2m 的 MEM 的内存空间分为两部分:
- Permuted Memory,大小为 m + m m+\sqrt m m+m,其中的 m m m 个 Cell 存储原始 R A M m RAM_m RAMm 的 MEM 中存储的 m m m 个 word,另外的 m \sqrt m m 个 Cell 存储 dummy word,随机置换 π \pi π 作用在整个主存上
- Shelter,大小为 m \sqrt m m,作用类似于 Cache,随机置换 π \pi π 并不作用在 Cache 上
同时,令 ORAM 按照 epoch 将时间分片,每个时期 CPU 将执行 m \sqrt m m 次访存,然后对 MEM 重新执行一次随机置换,从而降低均摊成本。
初始化:将 ORAM 的 permuted memory 填充上 virtual words 以及 dummy words,将 shelter 的所有单元都置为 empty(注意区分 empty 和 dummy)
模拟过程:ORAM 按照 epochs 依次执行,每个 epoch 至多包含 m \sqrt m m 次 virtual address 的访存,
-
随机选择 [ m + m ] [m+\sqrt m] [m+m] 上的置换 π \pi π,将 permuted memory 中的虚拟地址 i i i 上的 word,映射到实际地址 π ( i ) \pi(i) π(i),这儿的置换操作是 Oblivious 的
- 令 n = m + m n=m+\sqrt m n=m+m,通过访问 RO(或者 PRF) τ : { 1 , 2 , ⋯ , n } → T n : = { 1 , 2 , ⋯ , n 2 / ϵ } \tau:\{1,2,\cdots,n\} \to T_n:=\{1,2,\cdots,n^2/\epsilon\} τ:{1,2,⋯,n}→Tn:={1,2,⋯,n2/ϵ},其中 ϵ \epsilon ϵ 是碰撞概率,我们可以得到序列 { τ ( i ) : 1 ≤ i ≤ n } \{\tau(i):1\le i \le n\} {τ(i):1≤i≤n},将它作为排序的 tag
- 我们使用 [Batcher68] 的排序网络(它是数据无关的,因此 Oblivious)根据这个 tag 对 permuted memory 排序,就完成了随机置换
- 在后续的访存时,因为 τ ( i ) \tau(i) τ(i) 是有序排列的,因此可以二叉搜索
-
每当 RAM 读/写虚拟地址 i i i 上的 word,假设这是第 j j j 次访存(用 CPU 寄存器 c o u n t count count 记录)
- ORAM 扫描 shelter 中的所有 Cell,查找是否存在 virtual word i i i(每个 Cell 同时存储了 virtual address 以及 virtual word),并使用 CPU 中的布尔寄存器 f o u n d found found 记录
- 如果没找到( f o u n d = 0 found=0 found=0),那么 CPU 访问 permuted memory 的位置 π ( i ) \pi(i) π(i),将对应的 word 存储到 CPU 寄存器里
- 如果找到了( f o u n d = 1 found=1 found=1),那么 CPU 把这个 word 存储到寄存器里,同时访问第 j j j 个 dummy word 假装访存(实际地址 π ( m + c o u n t ) \pi(m+count) π(m+count))以使得 step 2.2 和 step 2.3 的执行时间相同
- CPU 执行一定的计算后,将更新的数据 virtual word i i i(即使内容没发生改变)写入到 shelter 中,假如是新增数据项则找到 empty 位置写入,这个过程是 Oblivious 的(通过 scan 整个 shelter,并用 f o u n d found found 记录)
-
结束当前 epoch 时,将 shelter 中累积的脏数据写回到 permuted memory 中,这个过程也是 Oblivious 的
- 我们用 ( v , s ) (v,s) (v,s) 标记整个 MEM(包括 permuted memory 以及 shelter),其中 v v v 是 Cell 存储的 virtual address, s ∈ { 0 , 1 } s \in \{0,1\} s∈{0,1} 表示数据来自 permuted memory 还是 shelter(用于区分新/旧数据),对于 dummy 以及 empty 赋值 v = ∞ v=\infty v=∞
- 以字典序用 [Batcher68] 排序,使得 ( v , 0 ) , ( v , 1 ) (v,0),(v,1) (v,0),(v,1) 紧挨着(如果 word 更新过),扫描整个 MEM 清理旧数据 ( v , 0 ) (v,0) (v,0)(没更新过的不需清理)
- 再次 [Batcher68] 排序(现在的 tag 只有 v v v 了),将 MEM 分区为 permuted memory 以及 shelter(其实这次排序可以合并到 step 1 的随机置换中),现在的 shelter 重新是 empty 的了。
因为排序网络是数据无关的,step 1 和 step 3 是不经意的。由于 CPU 寄存器不可被敌手观察,因此 step 2.2 和 step 2.3 是不经意的。因为 CPU 扫描了整个 Shelter,因此 step 2.1 和 step 2.4 是不经意的。最终,平方根算法是一个 Oblivious Simulation,一个 epoch 计算复杂度为 O ( m log 2 m ) + m ⋅ O ( 2 m + log ( m + m ) ) O(m \log^2 m) + \sqrt m \cdot O(2\sqrt m + \log(m+\sqrt m)) O(mlog2m)+m⋅O(2m+log(m+m)),均摊的减速为 O ( m ⋅ log 2 m ) O(\sqrt m \cdot \log^2 m) O(m⋅log2m)。
实际上,将 Shelter 的大小设置为 m ⋅ log m \sqrt m \cdot \log m m⋅logm 将达到最优化,使用渐进复杂度更低的 [AKS83] 排序网络(但是隐藏的常数很大)成本也可以更低。
The Hierarchical Solution
但是平方根算法的减速依然是 p o l y ( m ) poly(\sqrt m) poly(m) 量级的,正如但是往往 m ≫ t m \gg t m≫t,导致大内存 RAM 的模拟开销巨大。假设我们用一个 powerful CPU,它拥有 m \sqrt m m 个寄存器,就可以把 shelter 移动到 CPU 里,从而不需要一次次 scan 了。不过这依然不能达到 p o l y ( log ( t ) ) poly(\log(t)) poly(log(t)) 开销。
[G096] 给出了层次算法,使用不同大小的 Hash Table 排列成层次结构(类似于 L1 Cache、L2 Cache、Main Memory 的关系),越大的表更新频率越慢(类似于 Huffman Tree),可以将开销降低到仅仅 O ( log 3 t ) O(\log^3 t) O(log3t)
Simple Case
现在我们考虑受限的情形:virtual memory 的所有 words 都至多被访问一次。简记 A = { ( V 1 , X 1 ) , ⋯ , ( V n , X n ) } A=\{(V_1,X_1),\cdots,(V_n,X_n)\} A={(V1,X1),⋯,(Vn,Xn)} 是 n n n 个字,其中 V i V_i Vi 是虚拟地址, X i X_i Xi 是数值。
- 创建一个具有 n n n 个 bucket 的 Hash table,每个桶的大小为 l = O ( log n ) l=O(\log n) l=O(logn)(主要是为了降低数据溢出的概率,大小任意取)
- 使用 RO 计算哈希函数 h h h,将数据 ( V i , X i ) (V_i,X_i) (Vi,Xi) 存放到第 h ( V i ) h(V_i) h(Vi) 个桶的第一个 可用位置(假如溢出了,重新选择 h h h),这儿的 Hash 是以 Oblivious 方式执行的
- 当访问第 i i i 个数据时,扫描第 h ( V i ) h(V_i) h(Vi) 个桶里的全部 Cell,这必然可以找到数据 X i X_i Xi
- 数据更新时,直接对第 h ( V i ) h(V_i) h(Vi) 个桶里的 Cell 覆写即可
我们称 t t t-legal sequence 是一个虚拟地址访问模式,包括 t t t 次访存,且每个地址都仅被访问一次。对于虚拟地址 V 1 , ⋯ , V n V_1,\cdots,V_n V1,⋯,Vn,只要做 Hash 映射后没有超过 l = O ( log n ) l=O(\log n) l=O(logn) 个原像映射到同一个像,那么对于任意的 t ≤ n t \le n t≤n 的 legal sequence,上述 ORAM 的实际地址访问模式都是同分布的。即,对于受限情形,这个 ORAM 是 Oblivious 的。
General Case
现在我们考虑一般情况:访存模式中存在对于某地址的多次访问。只要我们将数据不断地使用新的 Hash Function 将数据(Oblivious)哈希到各个桶里,使得相邻两次 Hash 之间哈希表里的每个数据被至多访问一次,那么就转化为 Simple Case 了。我们使用多个大小不同的 Hash Table,并且它们的更新频率与大小成反比。
假设当前的访存模式长度为 t t t,我们申请 N = 1 + ⌈ log t ⌉ N=1+\lceil\log t\rceil N=1+⌈logt⌉ 块 buffers,第 i i i 个 buffer 包括 2 i 2^i 2i 个桶,每个桶的大小为 m = O ( log t ) m=O(\log t) m=O(logt)(仅仅是为了防止数据溢出)。我们将各个 buffer 的时间也划分为 epochs,第 i i i 个 buffer 的时间长度为 2 i − 1 2^{i-1} 2i−1(时间单位是访存次数,第一个哈希表的 epoch 长度为 1 1 1,但它并不主动重新散列;第二个哈希表每经过 2 2 2 次访存就重新散列,同时合并第一个哈希表的数据)。
我们令 h i j : { 0 , 1 } k → { 1 , 2 , ⋯ , 2 i } h_{ij}:\{0,1\}^k \to \{1,2,\cdots,2^i\} hij:{0,1}k→{1,2,⋯,2i} 是第 i i i 个 buffer 在它的第 j j j 个 epoch 使用的虚拟地址散列函数,它们相互独立且均匀随机,例如 h i j ( V ) : = f ( i , j , V ) ( m o d 2 i ) h_{ij}(V):=f(i,j,V) \pmod{2^i} hij(V):=f(i,j,V)(mod2i),这里 f f f 是个 RO。注意,随着时间 t t t 的推进,buffer 的数量 N N N 和 bucket 的大小 m m m 都在增加(在时刻 t = 2 i t=2^i t=2i),不过增加的频率指数级减慢。已经分配了内存的 buffer 不需要调整 m m m,毕竟它只是为了减少溢出概率,而上层的 buffer 中存储的数据量本来就不大。在时刻 t t t,大约消耗了 O ( t ⋅ log 2 t ) O(t \cdot \log^2 t) O(t⋅log2t) 的存储空间。
初始化:
- 对于 R A M k ( y ) RAM_k(y) RAMk(y),初始设置 t = ∣ y ∣ t=|y| t=∣y∣,申请 N N N 个 buffers,它们的桶都是 empty 状态
- 使用 h N , 1 h_{N,1} hN,1,将输入数据 y y y 散列到第 N N N 个哈希表上(最底层的 main memory),它包含 2 N ≥ 2 ∣ y ∣ 2^N\ge 2|y| 2N≥2∣y∣ 个桶(保持桶的数量至少大于所存储的虚拟数据个数)
模拟过程:时刻 t t t 原始 RAM 访问虚拟地址 V V V 的数据,
- ORAM 扫描第一个 buffer 全部的两个桶的 Cell,查找虚拟地址 V V V 的数据项(CPU 使用寄存器 f o u n d found found 记录),如果找到了就把 ( V , X ) (V,X) (V,X) 存储到寄存器中,并设置 f o u n d = 1 found=1 found=1
- 如果前 i − 1 i-1 i−1 个 buffer 中都没找到( f o u n d = 0 found=0 found=0),那么继续扫描第 i i i 个 buffer 的第 h i , ⌈ t / 2 i − 1 ⌉ ( V ) h_{i, \lceil t/2^{i-1}\rceil}(V) hi,⌈t/2i−1⌉(V) 个桶(其他桶中不会存储虚拟地址 V V V 的数据),
- 如果前 i − 1 i-1 i−1 个 buffer 中已经找到了( f o u n d = 1 found=1 found=1),那么随机扫描第 i i i 个 buffer 任意一个桶(比如 h i , ⌈ t / 2 i − 1 ⌉ ( 0 , t ) h_{i, \lceil t/2^{i-1}\rceil}(0,t) hi,⌈t/2i−1⌉(0,t)),使得运行时间相同。注意,每个 buffer 都要扫描,不允许提前终止
- 无论数据是否更新,CPU 都要将 ( V , X ) (V,X) (V,X) 写回 MEM,ORAM 把它 obliviously 写到第一个 buffer 里,如果数据项已经存在那么就覆写,否则写入一个 empty 位置
- 在时刻 t t t,令 i = 1 , 2 , ⋯ , max { j : 2 j ∣ t } i=1,2,\cdots,\max\{j:2^{j} \mid t\} i=1,2,⋯,max{j:2j∣t} 是那些当前 epoch 恰好结束的 buffers。我们将第 i i i 个 buffer(时期长度 2 i − 1 2^{i-1} 2i−1)和第 i + 1 i+1 i+1 个 buffer(时期长度 2 i 2^{i} 2i)所存储的 virtual words 全都使用 h i + 1 , 1 + t / 2 i h_{i+1, 1+t/2^i} hi+1,1+t/2i 重新散列到第 i + 1 i+1 i+1 个 buffer 内,同时将第 i i i 个 buffer 的所有 actual words 都设为 empty(清理掉上层 cache 中累积的脏数据)。如果第 i + 1 i+1 i+1 个 buffer 不存在,那么就申请一个新的 buffer
注意,当 t t t 是奇数时 max { j : 2 j ∣ t } = 0 \max\{j:2^{j} \mid t\}=0 max{j:2j∣t}=0,只有第 i = 1 i=1 i=1 个 buffer 的时期结束了,第二个 buffer 的时期还没结束,因此第 i + 1 i+1 i+1 个 buffer 的数据不需要重新散列(也不合并前 i i i 个 buffer 的数据)。此时 step 5 没有做任何事,虽然 h 1 , t h_{1,t} h1,t 被变更了(不影响 step 1 的 oblivious read,它同时扫描了两个桶)。
每经过 2 i + 1 2^{i+1} 2i+1 时间,第 i + 2 i+2 i+2 个 buffer 重新散列时将会清空前 i + 1 i+1 i+1 个 buffer。在第 ( 2 q + 1 ) ⋅ 2 i (2q+1) \cdot 2^i (2q+1)⋅2i 时刻,前 i i i 个 buffer 中至多被写入了 2 i 2^i 2i 个脏数据,此时第 i + 1 i+1 i+1 个 buffer 是空的,重新散列将这 2 i 2^i 2i 个数据合并到第 i + 1 i+1 i+1 个 buffer(含有 2 i + 1 2^{i+1} 2i+1 个桶)中。然后在第 ( 2 q + 2 ) ⋅ 2 i (2q+2) \cdot 2^i (2q+2)⋅2i 时刻,前 i i i 个 buffer 中的至多 2 i 2^i 2i 个数据以及第 i + 1 i+1 i+1 个 buffer 中的至多 2 i 2^i 2i 个数据合并到第 i + 1 i+1 i+1 个 buffer 中,紧接着就被合并到了第 i + 2 i+2 i+2 个 buffer 内。因此,第 i + 1 i+1 i+1 个 buffer 中所存储的巅峰数据量大小为 2 i + 1 2^{i+1} 2i+1,发生数据溢出的可能极小。同时由于每次 epoch 结束都更换 Hash 函数,因此碰撞也不会累积。
Oblivious Hash
现在我们实现模拟过程中 step 5 里的 Oblivious re-hash 功能。抽象地,有两个数组 A , B A,B A,B:数组 A A A 含有 n n n 个桶,存储了至多 n n n 个(新)数据;数组 B B B 含有 2 n 2n 2n 个桶,存储了至多 n n n 个(旧)数据。我们将这至多 2 n 2n 2n 个数据,散列到数组 B B B 内。
[GO96] 将数组 A , B A,B A,B 合并为长度 n m + 2 n m nm+2nm nm+2nm 的数组,通过 [AKS83] 排序网络,类似于平方根算法,清理掉旧数据。然后插入一些占位符,再次排序使得前 2 n m 2nm 2nm 前缀出现桶结构,截断为 merge 之后的数组 B B B。
把上述的 Oblivious Hash 应用到层次算法的 re-hash 过程中,便得到了一个 ORAM。使用 AKS 排序网络,时间复杂度为 O ( t log 3 t ) O(t \log^3 t) O(tlog3t),减速因子 O ( log 3 t ) O(\log^3 t) O(log3t);使用 Batcher 排序网络,时间复杂度为 O ( t log 4 t ) O(t \log^4 t) O(tlog4t),减速因子 O ( log 4 t ) O(\log^4 t) O(log4t)。在整个 Oblivious Simulation 过程中,CPU 仅仅需要 3 3 3 个寄存器:两个用于存储 MEM 发送的数据(比较运算),一个用于 t t t 的计时(其实还有个布尔寄存器 f o u n d found found)。
[GO96] 给出了 ORAM 的高效构造: