mirror of
https://github.com/hanzoai/cek.git
synced 2026-10-09 02:37:18 +00:00
A file's key comes from two joins: an owner and a file id joined into one identity, and a namespace and a subsystem joined into one info string. Both joins were described but never stated — the vectors took the pieces already joined, so they held for any way of joining them. These state them. Every expected value is a literal from an implementation of the scheme written separately from this one, in another language, from the original source: hanzoai/sqlite v0.4.0 and hanzoai/cloud v1.801.360. That implementation reproduces the vectors already here exactly, which is what makes it a reference rather than a second opinion. A sidecar sealed by it is opened here from nothing but a namespace and a file on disk, so the reader has to arrive at the owner and the file rather than be handed them. The encoding is read back by a reader written from its definition, over lengths that run past 128, where the length prefix grows a second byte, and past 16384, where it grows a third. The derivation is unchanged. Co-authored-by: Hanzo Dev <[email protected]>
431 lines
14 KiB
Go
431 lines
14 KiB
Go
package cek
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/namespace"
|
|
sqlitedrv "github.com/hanzoai/sqlite"
|
|
)
|
|
|
|
// The identity a sidecar's key was derived under, pinned as literal bytes.
|
|
//
|
|
// sidecar_golden_test.go pins wrapKey and unwrapDEK for identities written out by
|
|
// hand. That leaves the function that BUILDS an identity from a namespace and a
|
|
// file id unpinned, and it is the one carrying the owner: change how the owner
|
|
// and the file are joined and every sidecar on disk derives a different key,
|
|
// while a suite that hands the pieces in already joined stays green.
|
|
//
|
|
// So these state the composition itself. Every expected value below is a literal
|
|
// produced by an implementation of the scheme written separately from this one,
|
|
// in another language, from the original source — hanzoai/sqlite v0.4.0's
|
|
// lengthPrefixedInfo/DeriveKey/WrapDEK and hanzoai/cloud v1.801.360's
|
|
// derivationID and nsPrincipal. That implementation reproduces the vectors in
|
|
// sidecar_golden_test.go exactly, which is what makes it a reference rather than
|
|
// a second opinion.
|
|
|
|
// fileID is the 16 random bytes a sidecar opens with. Fixed here so the
|
|
// identities below are literals rather than something a test computes.
|
|
func fileID() []byte {
|
|
f := make([]byte, fileIDLen)
|
|
for i := range f {
|
|
f[i] = byte(i)
|
|
}
|
|
return f
|
|
}
|
|
|
|
const fileIDHex = "000102030405060708090a0b0c0d0e0f"
|
|
|
|
// The owner and the file, in that order. A system namespace owns its files as
|
|
// the platform and carries the bare file id; every other namespace carries its
|
|
// own id ahead of it.
|
|
func TestTheIdentityAFileIsWrappedUnder(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
ns namespace.Namespace
|
|
ptype string
|
|
id string
|
|
}{
|
|
{
|
|
name: "an org",
|
|
ns: namespace.MustOrg("hanzo"),
|
|
ptype: "org",
|
|
id: "hanzo/" + fileIDHex,
|
|
},
|
|
{
|
|
// The group names which of an org's files this is; the file id already
|
|
// tells one file from another, so the group is not part of the identity.
|
|
name: "a project of that org",
|
|
ns: namespace.MustOrgProject("acme", "web"),
|
|
ptype: "org",
|
|
id: "acme/" + fileIDHex,
|
|
},
|
|
{
|
|
name: "the platform",
|
|
ns: namespace.System(),
|
|
ptype: "global",
|
|
id: fileIDHex,
|
|
},
|
|
{
|
|
name: "a group of the platform's own files",
|
|
ns: namespace.System().WithGroup(namespace.MustGroup("notices")),
|
|
ptype: "global",
|
|
id: fileIDHex,
|
|
},
|
|
{
|
|
name: "an org named like the platform",
|
|
ns: namespace.MustOrg("global"),
|
|
ptype: "org",
|
|
id: "global/" + fileIDHex,
|
|
},
|
|
{
|
|
// A name chosen to read as a bare file id. It stays an org.
|
|
name: "an org named like a file id",
|
|
ns: namespace.MustOrg("0011223344556677889aabbccddeeff0"),
|
|
ptype: "org",
|
|
id: "0011223344556677889aabbccddeeff0/" + fileIDHex,
|
|
},
|
|
{
|
|
name: "an org id carrying the shape a folded name gets",
|
|
ns: namespace.MustOrg("acme-0123456789abcdef"),
|
|
ptype: "org",
|
|
id: "acme-0123456789abcdef/" + fileIDHex,
|
|
},
|
|
{
|
|
name: "the longest org id there is",
|
|
ns: namespace.MustOrg("a" + strings.Repeat("b", 63)),
|
|
ptype: "org",
|
|
id: "a" + strings.Repeat("b", 63) + "/" + fileIDHex,
|
|
},
|
|
{
|
|
// Not a shape any store has, but it is the branch every namespace that
|
|
// is not the platform takes, and it is what the original mapped too.
|
|
name: "a namespace that names some other entity",
|
|
ns: namespace.MustRepo("r1"),
|
|
ptype: "org",
|
|
id: "r1/" + fileIDHex,
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ptype, id := wrapIdentity(tc.ns, fileID())
|
|
if ptype != tc.ptype || id != tc.id {
|
|
t.Fatalf("the identity drifted from the one on disk:\n got (%q, %q)\nwant (%q, %q)", ptype, id, tc.ptype, tc.id)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Two file ids under one owner, to pin that the file is rendered as hex of
|
|
// exactly the 16 bytes it is and nothing else is folded in.
|
|
func TestTheFileIsRenderedAsItself(t *testing.T) {
|
|
for _, tc := range []struct{ name, raw, id string }{
|
|
{"a zero file id", strings.Repeat("00", fileIDLen), "hanzo/" + strings.Repeat("00", fileIDLen)},
|
|
{"an all-ones file id", strings.Repeat("ff", fileIDLen), "hanzo/" + strings.Repeat("ff", fileIDLen)},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
raw, err := hex.DecodeString(tc.raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ptype, id := wrapIdentity(namespace.MustOrg("hanzo"), raw)
|
|
if ptype != principalOrg || id != tc.id {
|
|
t.Fatalf("got (%q, %q), want (%q, %q)", ptype, id, principalOrg, tc.id)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The bytes an identity renders to, pinned literally.
|
|
func TestThePrincipalEncoding(t *testing.T) {
|
|
for _, tc := range []struct{ name, ptype, id, want string }{
|
|
{
|
|
name: "an org's file",
|
|
ptype: "org",
|
|
id: "hanzo/" + fileIDHex,
|
|
want: "036f72672668616e7a6f2f3030303130323033303430353036303730383039306130623063306430653066",
|
|
},
|
|
{
|
|
name: "the platform's own file",
|
|
ptype: "global",
|
|
id: fileIDHex,
|
|
want: "06676c6f62616c203030303130323033303430353036303730383039306130623063306430653066",
|
|
},
|
|
{
|
|
name: "the longest identity a namespace can produce",
|
|
ptype: "org",
|
|
id: strings.Repeat("a", 64) + "/" + fileIDHex,
|
|
want: "036f726761616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161612f3030303130323033303430353036303730383039306130623063306430653066",
|
|
},
|
|
{
|
|
// The pair that shows the length prefix earning its place: run these
|
|
// together without one and both read "abc".
|
|
name: "a type that ends where the id begins",
|
|
ptype: "ab",
|
|
id: "c",
|
|
want: "0261620163",
|
|
},
|
|
{
|
|
name: "the same letters split the other way",
|
|
ptype: "a",
|
|
id: "bc",
|
|
want: "0161026263",
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := hex.EncodeToString(principalInfo(tc.ptype, tc.id)); got != tc.want {
|
|
t.Fatalf("the encoding drifted:\n got %s\nwant %s", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// read is a reader for the encoding, written from its definition rather than
|
|
// from the encoder. principalInfo has no decoder in the package, so this is a
|
|
// second implementation and not the first one run backwards: it is what lets
|
|
// "these bytes mean exactly one identity" be tested at all.
|
|
func read(t *testing.T, b []byte) (ptype, id string) {
|
|
t.Helper()
|
|
field := func(b []byte) ([]byte, []byte) {
|
|
n, w := binary.Uvarint(b)
|
|
if w <= 0 {
|
|
t.Fatalf("no length at the front of %x", b)
|
|
}
|
|
b = b[w:]
|
|
if uint64(len(b)) < n {
|
|
t.Fatalf("length says %d, only %d bytes left", n, len(b))
|
|
}
|
|
return b[:n], b[n:]
|
|
}
|
|
tb, rest := field(b)
|
|
ib, rest := field(rest)
|
|
if len(rest) != 0 {
|
|
t.Fatalf("%d bytes left over", len(rest))
|
|
}
|
|
return string(tb), string(ib)
|
|
}
|
|
|
|
// One identity, one encoding, and the encoding says which. Lengths run across
|
|
// 128, where the prefix grows a second byte, and across 16384, where it grows a
|
|
// third: those are the two places a length prefix stops being one byte and a
|
|
// reader that assumed otherwise would start reading an identity as another.
|
|
func TestThePrincipalEncodingSaysWhichIdentityItIs(t *testing.T) {
|
|
lengths := []int{1, 2, 3, 126, 127, 128, 129, 130, 16382, 16383, 16384, 16385}
|
|
|
|
seen := map[string][2]int{}
|
|
for _, lt := range []int{1, 2, 3, 127, 128, 129} {
|
|
for _, li := range lengths {
|
|
ptype, id := strings.Repeat("t", lt), strings.Repeat("i", li)
|
|
enc := principalInfo(ptype, id)
|
|
|
|
gotType, gotID := read(t, enc)
|
|
if gotType != ptype || gotID != id {
|
|
t.Fatalf("a type of %d and an id of %d read back as %d and %d", lt, li, len(gotType), len(gotID))
|
|
}
|
|
if was, ok := seen[string(enc)]; ok {
|
|
t.Fatalf("(%d, %d) encodes the same as (%d, %d)", lt, li, was[0], was[1])
|
|
}
|
|
seen[string(enc)] = [2]int{lt, li}
|
|
}
|
|
}
|
|
}
|
|
|
|
// An org id and a file id must not run together into a string that could be read
|
|
// as some other pair. Two things keep them apart: a namespace id is
|
|
// [a-z0-9][a-z0-9_-]* and so carries no separator, and the file is always the
|
|
// last 32 characters because it is always 16 bytes. Either alone settles it, so
|
|
// this checks both hold for names a person can choose.
|
|
func TestTheOwnerAndTheFileStayApart(t *testing.T) {
|
|
names := []string{
|
|
"acme",
|
|
"global",
|
|
"acme/0011223344556677889aabbccddeeff",
|
|
"acme/" + fileIDHex,
|
|
"/" + fileIDHex,
|
|
"a/b/c",
|
|
"ACME",
|
|
"acme.",
|
|
"acme-0123456789abcdef",
|
|
"0011223344556677889aabbccddeeff0",
|
|
strings.Repeat("z", 64),
|
|
"org",
|
|
"système",
|
|
"acme:" + fileIDHex,
|
|
}
|
|
|
|
for _, name := range names {
|
|
t.Run(name, func(t *testing.T) {
|
|
ns, err := namespace.OrgProject(name, "")
|
|
if err != nil {
|
|
t.Skipf("%q is not a name a namespace can be built from: %v", name, err)
|
|
}
|
|
ptype, id := wrapIdentity(ns, fileID())
|
|
if ptype != principalOrg {
|
|
t.Fatalf("an org rendered as %q", ptype)
|
|
}
|
|
if strings.Contains(ns.ID(), "/") {
|
|
t.Fatalf("the org id %q carries a separator, so the owner no longer ends where the file begins", ns.ID())
|
|
}
|
|
|
|
// Read the pair back the two independent ways, and require both to
|
|
// return the owner and the file that went in.
|
|
owner, file, ok := strings.Cut(id, "/")
|
|
if !ok || owner != ns.ID() || file != fileIDHex {
|
|
t.Fatalf("splitting %q at its first separator gave (%q, %q), want (%q, %q)", id, owner, file, ns.ID(), fileIDHex)
|
|
}
|
|
if len(id) < len(fileIDHex)+1 || id[len(id)-len(fileIDHex)-1] != '/' {
|
|
t.Fatalf("%q does not end in a separator and 32 hex characters", id)
|
|
}
|
|
if got := id[len(id)-len(fileIDHex):]; got != fileIDHex {
|
|
t.Fatalf("the last %d characters of %q are %q, want the file id", len(fileIDHex), id, got)
|
|
}
|
|
if got := id[:len(id)-len(fileIDHex)-1]; got != ns.ID() {
|
|
t.Fatalf("what is left of %q is %q, want the owner %q", id, got, ns.ID())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The platform is not a tenant, and no org can be named into becoming it. The
|
|
// type is the first field of the encoding and it is length-prefixed, so the two
|
|
// forms differ at byte zero however the rest is chosen.
|
|
func TestNoOrgCanBeNamedIntoThePlatform(t *testing.T) {
|
|
platform := principalInfo(wrapIdentity(namespace.System(), fileID()))
|
|
|
|
for _, name := range []string{
|
|
"global",
|
|
fileIDHex,
|
|
"global-" + fileIDHex,
|
|
"0011223344556677889aabbccddeeff0",
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
ns, err := namespace.OrgProject(name, "")
|
|
if err != nil {
|
|
t.Skipf("not a usable name: %v", err)
|
|
}
|
|
org := principalInfo(wrapIdentity(ns, fileID()))
|
|
if string(org) == string(platform) {
|
|
t.Fatalf("the org %q encodes exactly as the platform does", name)
|
|
}
|
|
if org[0] == platform[0] {
|
|
t.Fatalf("the org %q and the platform start with the same byte %#x", name, org[0])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The wrapping key for an identity this package COMPOSED, pinned to the key the
|
|
// original derived. This is the composition and the derivation in one statement:
|
|
// a change to either end fails here.
|
|
func TestTheWrappingKeyForAComposedIdentity(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
ns namespace.Namespace
|
|
kek string
|
|
}{
|
|
{"an org", namespace.MustOrg("hanzo"), "ab95e9935e6a8e430c579b0189a1ce7b438ba3a68f0d3c6ec49b99f5194eb265"},
|
|
{"the platform", namespace.System(), "7d66331c88e1d7a2344ca752590c80deb88ad6d73c19ef14c2065c33fc431487"},
|
|
{"another org", namespace.MustOrg("acme-2"), "ccf8ba20b8f73b9a33cfed386805cbfbcfae2e506facba312006e6bc49c12e9a"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ptype, id := wrapIdentity(tc.ns, fileID())
|
|
kek, err := wrapKey(goldenMaster(), ptype, id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := hex.EncodeToString(kek); got != tc.kek {
|
|
t.Fatalf("the wrapping key drifted:\n got %s\nwant %s", got, tc.kek)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A sidecar sealed by the other implementation, opened by this one.
|
|
//
|
|
// Nothing here tells the reader which identity to use: it is handed a namespace
|
|
// and a file on disk, and has to arrive at the same owner-and-file the sealer
|
|
// used. That is the property the composition exists for, and the reason these
|
|
// blobs are literals — sealing them here with the same function that reads them
|
|
// would agree with itself whatever it did.
|
|
func TestASidecarSealedElsewhereOpensHere(t *testing.T) {
|
|
const dek = "fffefdfcfbfaf9f8f7f6f5f4f3f2f1f0efeeedecebeae9e8e7e6e5e4e3e2e1e0"
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
ns namespace.Namespace
|
|
subsystem string
|
|
blob string
|
|
}{
|
|
{
|
|
name: "an org's file",
|
|
ns: namespace.MustOrg("hanzo"),
|
|
subsystem: "kms",
|
|
blob: "01a1b2c3d4e5f60718293a4b5cde06ded7ee709daf4af9689176bb794a13c2640f5a04c14088bcf6fef6b46a2d309d1e8dfa898b147bba7fb7e19002a3",
|
|
},
|
|
{
|
|
name: "the platform's own file",
|
|
ns: namespace.System(),
|
|
subsystem: "treasury",
|
|
blob: "01a1b2c3d4e5f60718293a4b5c715726d14491a936bf930fe407fe32c1253e96ead3d4df7963e07111f127d3ba8978f5011d041c8dd85be9ad4cc72a09",
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
master := goldenMaster()
|
|
if err := SetMaster(master); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dir := t.TempDir()
|
|
path, err := namespace.Path(dir, tc.ns, tc.subsystem)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
key, err := hex.DecodeString(dek)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db, err := sqlitedrv.OpenDB(path, key)
|
|
if err != nil {
|
|
t.Fatalf("write a database under the sealed key: %v", err)
|
|
}
|
|
if _, err := db.Exec(`CREATE TABLE secret (v TEXT)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.Exec(`INSERT INTO secret VALUES ('sealed elsewhere')`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
blob, err := hex.DecodeString(tc.blob)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path+sidecarSuffix, append(fileID(), blob...), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
opened, err := Open(tc.ns, tc.subsystem, dir)
|
|
if err != nil {
|
|
t.Fatalf("open a database whose key was sealed by the other implementation: %v", err)
|
|
}
|
|
defer opened.Close()
|
|
|
|
var v string
|
|
if err := opened.QueryRow(`SELECT v FROM secret`).Scan(&v); err != nil {
|
|
t.Fatalf("read the row: %v", err)
|
|
}
|
|
if v != "sealed elsewhere" {
|
|
t.Fatalf("read %q", v)
|
|
}
|
|
})
|
|
}
|
|
}
|