Initial Commit
This commit is contained in:
71
packages/networking/drive/drive-template/tests/_helpers.ts
Normal file
71
packages/networking/drive/drive-template/tests/_helpers.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Shared helpers for the drive-template test suite. Builds canonical
|
||||
* sample templates, signs them, and exposes injectable clocks +
|
||||
* deterministic Ed25519 keypairs so individual tests stay focused on
|
||||
* the requirement they verify.
|
||||
*/
|
||||
|
||||
import {
|
||||
getPublicKey,
|
||||
randomPrivateKey,
|
||||
} from "@suaveplan/crypto-curve/ed25519";
|
||||
import { toHex } from "@suaveplan/crypto-utils";
|
||||
import { signTemplate } from "../src/codec/sign.js";
|
||||
import type {
|
||||
DriveTemplate,
|
||||
FolderTemplate,
|
||||
PerFilePolicy,
|
||||
} from "../src/types/template.types.js";
|
||||
|
||||
export interface SampleKeys {
|
||||
readonly secret: Uint8Array;
|
||||
readonly publicHex: string;
|
||||
}
|
||||
|
||||
export function makeKeys(): SampleKeys {
|
||||
const secret = randomPrivateKey();
|
||||
const publicHex = toHex(getPublicKey(secret));
|
||||
return { secret, publicHex };
|
||||
}
|
||||
|
||||
export function makeNonce(value = 7): Uint8Array {
|
||||
return new Uint8Array(16).fill(value);
|
||||
}
|
||||
|
||||
export const DEFAULT_POLICY: PerFilePolicy = {
|
||||
autoDownload: false,
|
||||
meshAllowed: true,
|
||||
cdnAllowed: true,
|
||||
retention: "lru",
|
||||
};
|
||||
|
||||
export function emptyRoot(): FolderTemplate {
|
||||
return { name: "/", children: [] };
|
||||
}
|
||||
|
||||
export interface BuildOptions {
|
||||
readonly issuer: string;
|
||||
readonly nonce?: Uint8Array;
|
||||
readonly issuedAt?: number;
|
||||
readonly expiresAt?: number;
|
||||
readonly rootFolder?: FolderTemplate;
|
||||
}
|
||||
|
||||
export function buildTemplate(
|
||||
options: BuildOptions
|
||||
): Omit<DriveTemplate, "serverSignature"> {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
driveId: "drive-1",
|
||||
rootFolder: options.rootFolder ?? emptyRoot(),
|
||||
policy: { defaultPolicy: DEFAULT_POLICY },
|
||||
issuer: options.issuer,
|
||||
issuedAt: options.issuedAt ?? Date.now() - 1_000,
|
||||
expiresAt: options.expiresAt ?? Date.now() + 24 * 60 * 60 * 1000,
|
||||
nonce: options.nonce ?? makeNonce(),
|
||||
};
|
||||
}
|
||||
|
||||
export function signed(options: BuildOptions, keys: SampleKeys): DriveTemplate {
|
||||
return signTemplate(buildTemplate(options), keys.secret);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Branch-coverage filler for canonical.ts encryption / retention /
|
||||
* optional-field paths and other defence-in-depth code that the
|
||||
* spec-tied tests don't exercise.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { canonicalSerialize } from "../src/codec/canonical.js";
|
||||
import type {
|
||||
DriveTemplate,
|
||||
FileTemplate,
|
||||
FolderTemplate,
|
||||
} from "../src/index.js";
|
||||
import { TemplateNonceLru, validateTemplate } from "../src/index.js";
|
||||
import {
|
||||
DEFAULT_POLICY,
|
||||
emptyRoot,
|
||||
makeKeys,
|
||||
makeNonce,
|
||||
signed,
|
||||
} from "./_helpers.js";
|
||||
|
||||
const baseFile: FileTemplate = {
|
||||
name: "x.bin",
|
||||
fileLocId: "loc-1",
|
||||
fileId: "f".repeat(64),
|
||||
size: 100,
|
||||
totalChunks: 1,
|
||||
chunkSize: 1024,
|
||||
checksum: "sha256:abcdef",
|
||||
flags: DEFAULT_POLICY,
|
||||
manifestKey: "manifest:f",
|
||||
manifestSig: new Uint8Array(64).fill(1),
|
||||
};
|
||||
|
||||
describe("canonicalSerialize encryption branches", () => {
|
||||
it("file with kind:inline encryption serializes", { timeout: 5000 }, () => {
|
||||
const tpl: Omit<DriveTemplate, "serverSignature"> = {
|
||||
schemaVersion: 1,
|
||||
driveId: "d",
|
||||
policy: { defaultPolicy: DEFAULT_POLICY },
|
||||
issuer: "00".repeat(32),
|
||||
issuedAt: 1,
|
||||
expiresAt: 1_000,
|
||||
nonce: makeNonce(),
|
||||
rootFolder: {
|
||||
name: "/",
|
||||
children: [
|
||||
{
|
||||
...baseFile,
|
||||
encryption: {
|
||||
algorithm: "xchacha20-poly1305",
|
||||
keyDistribution: {
|
||||
kind: "inline",
|
||||
wrappedKey: new Uint8Array(80).fill(0xab),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(canonicalSerialize(tpl).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("file with kind:retroactive encryption serializes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tpl: Omit<DriveTemplate, "serverSignature"> = {
|
||||
schemaVersion: 1,
|
||||
driveId: "d",
|
||||
policy: { defaultPolicy: DEFAULT_POLICY },
|
||||
issuer: "00".repeat(32),
|
||||
issuedAt: 1,
|
||||
expiresAt: 1_000,
|
||||
nonce: makeNonce(),
|
||||
rootFolder: {
|
||||
name: "/",
|
||||
children: [
|
||||
{
|
||||
...baseFile,
|
||||
encryption: {
|
||||
algorithm: "xchacha20-poly1305",
|
||||
keyDistribution: {
|
||||
kind: "retroactive",
|
||||
keyAuthority: "auth-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(canonicalSerialize(tpl).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("retention 'permanent' / 'lru' / { maxAgeMs } / undefined serialize", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const retention of [
|
||||
undefined,
|
||||
"permanent" as const,
|
||||
"lru" as const,
|
||||
{ maxAgeMs: 1000 },
|
||||
]) {
|
||||
const tpl: Omit<DriveTemplate, "serverSignature"> = {
|
||||
schemaVersion: 1,
|
||||
driveId: "d",
|
||||
policy: {
|
||||
defaultPolicy: { ...DEFAULT_POLICY, retention },
|
||||
},
|
||||
issuer: "00".repeat(32),
|
||||
issuedAt: 1,
|
||||
expiresAt: 1_000,
|
||||
nonce: makeNonce(),
|
||||
rootFolder: emptyRoot(),
|
||||
};
|
||||
expect(canonicalSerialize(tpl).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("GlobalPolicy with maxLocalCacheBytes / maxConcurrentFetches set", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tpl: Omit<DriveTemplate, "serverSignature"> = {
|
||||
schemaVersion: 1,
|
||||
driveId: "d",
|
||||
policy: {
|
||||
defaultPolicy: DEFAULT_POLICY,
|
||||
maxLocalCacheBytes: 1_000_000,
|
||||
maxConcurrentFetches: 4,
|
||||
trustedKeyAuthorities: ["a", "b"],
|
||||
},
|
||||
issuer: "00".repeat(32),
|
||||
issuedAt: 1,
|
||||
expiresAt: 1_000,
|
||||
nonce: makeNonce(),
|
||||
rootFolder: emptyRoot(),
|
||||
};
|
||||
expect(canonicalSerialize(tpl).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("CdnFallbackConfig with optional uploadEndpoint serialises", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tpl: Omit<DriveTemplate, "serverSignature"> = {
|
||||
schemaVersion: 1,
|
||||
driveId: "d",
|
||||
policy: { defaultPolicy: DEFAULT_POLICY },
|
||||
issuer: "00".repeat(32),
|
||||
issuedAt: 1,
|
||||
expiresAt: 1_000,
|
||||
nonce: makeNonce(),
|
||||
rootFolder: emptyRoot(),
|
||||
cdnFallback: {
|
||||
baseUrl: "https://example.com",
|
||||
pathScheme: "reverse-id",
|
||||
levels: 3,
|
||||
padding: 9,
|
||||
uploadEndpoint: "https://upload.example.com",
|
||||
},
|
||||
};
|
||||
expect(canonicalSerialize(tpl).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("CdnFallbackConfig WITHOUT uploadEndpoint covers optional false branch", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tpl: Omit<DriveTemplate, "serverSignature"> = {
|
||||
schemaVersion: 1,
|
||||
driveId: "d",
|
||||
policy: { defaultPolicy: DEFAULT_POLICY },
|
||||
issuer: "00".repeat(32),
|
||||
issuedAt: 1,
|
||||
expiresAt: 1_000,
|
||||
nonce: makeNonce(),
|
||||
rootFolder: emptyRoot(),
|
||||
cdnFallback: {
|
||||
baseUrl: "https://example.com",
|
||||
pathScheme: "reverse-id",
|
||||
levels: 3,
|
||||
padding: 9,
|
||||
},
|
||||
};
|
||||
expect(canonicalSerialize(tpl).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("nested folder serialises with TAG_FOLDER recursion", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const inner: FolderTemplate = { name: "deep", children: [] };
|
||||
const outer: FolderTemplate = { name: "/", children: [inner] };
|
||||
const tpl: Omit<DriveTemplate, "serverSignature"> = {
|
||||
schemaVersion: 1,
|
||||
driveId: "d",
|
||||
policy: { defaultPolicy: DEFAULT_POLICY },
|
||||
issuer: "00".repeat(32),
|
||||
issuedAt: 1,
|
||||
expiresAt: 1_000,
|
||||
nonce: makeNonce(),
|
||||
rootFolder: outer,
|
||||
};
|
||||
expect(canonicalSerialize(tpl).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema invocation coverage", () => {
|
||||
it("DriveTemplateSchema rejects nonce of wrong length", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { DriveTemplateSchema } = await import("../src/index.js");
|
||||
const r = DriveTemplateSchema.safeParse({
|
||||
schemaVersion: 1,
|
||||
driveId: "d",
|
||||
rootFolder: { name: "/", children: [] },
|
||||
policy: { defaultPolicy: DEFAULT_POLICY },
|
||||
issuer: "00".repeat(32),
|
||||
issuedAt: 1,
|
||||
expiresAt: 1_000,
|
||||
nonce: new Uint8Array(8), // wrong length
|
||||
serverSignature: new Uint8Array(64),
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("DriveTemplateSchema accepts a well-formed payload", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { DriveTemplateSchema } = await import("../src/index.js");
|
||||
const r = DriveTemplateSchema.safeParse({
|
||||
schemaVersion: 1,
|
||||
driveId: "d",
|
||||
rootFolder: { name: "/", children: [] },
|
||||
policy: { defaultPolicy: DEFAULT_POLICY },
|
||||
issuer: "00".repeat(32),
|
||||
issuedAt: 1,
|
||||
expiresAt: 1_000,
|
||||
nonce: makeNonce(),
|
||||
serverSignature: new Uint8Array(64),
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("FolderTemplateSchema parses recursive folder/file mix", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { FolderTemplateSchema } = await import("../src/index.js");
|
||||
const r = FolderTemplateSchema.safeParse({
|
||||
name: "/",
|
||||
children: [
|
||||
{
|
||||
name: "subfolder",
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
name: "file.bin",
|
||||
fileLocId: "loc",
|
||||
fileId: "fid",
|
||||
size: 1,
|
||||
totalChunks: 1,
|
||||
chunkSize: 1024,
|
||||
checksum: "blake3:abcd",
|
||||
flags: DEFAULT_POLICY,
|
||||
manifestKey: "k",
|
||||
manifestSig: new Uint8Array(64),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("depth check accepts a folder containing a file", () => {
|
||||
it("walk descends into folder, returns from file branch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { assertFolderDepth } = await import("../src/index.js");
|
||||
const tree: FolderTemplate = {
|
||||
name: "/",
|
||||
children: [baseFile],
|
||||
};
|
||||
expect(() => assertFolderDepth(tree)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validate.replay LRU empty-on-evict edge", () => {
|
||||
it("LRU eviction on size==capacity is a no-op pre-add", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const keys = makeKeys();
|
||||
const ctx = {
|
||||
clock: () => Date.now(),
|
||||
nonceLru: new TemplateNonceLru(2),
|
||||
};
|
||||
const tpl1 = signed(
|
||||
{ issuer: keys.publicHex, nonce: makeNonce(1) },
|
||||
keys
|
||||
);
|
||||
const tpl2 = signed(
|
||||
{ issuer: keys.publicHex, nonce: makeNonce(2) },
|
||||
keys
|
||||
);
|
||||
validateTemplate(tpl1, ctx);
|
||||
validateTemplate(tpl2, ctx);
|
||||
expect(ctx.nonceLru.size).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* DTP-17 — CDN reversed-ID URL convention.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { CdnFallbackConfig } from "../src/index.js";
|
||||
import {
|
||||
buildCdnChunkUrl,
|
||||
DEFAULT_CDN_LEVELS,
|
||||
DEFAULT_CDN_PADDING,
|
||||
} from "../src/index.js";
|
||||
|
||||
const BASE: CdnFallbackConfig = {
|
||||
baseUrl: "https://chunks.example.com",
|
||||
pathScheme: "reverse-id",
|
||||
levels: DEFAULT_CDN_LEVELS,
|
||||
padding: DEFAULT_CDN_PADDING,
|
||||
};
|
||||
|
||||
describe("DTP-17 — reverse-id URL convention", () => {
|
||||
it("URL builder produces correct path for various levels and padding values", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const url = buildCdnChunkUrl(BASE, "abcdef1234567890", 47);
|
||||
expect(url).toBe(
|
||||
"https://chunks.example.com/0/9/8/abcdef1234567890_000000047.chunk"
|
||||
);
|
||||
});
|
||||
|
||||
it("levels override changes the directory depth", { timeout: 5000 }, () => {
|
||||
const url = buildCdnChunkUrl(
|
||||
{ ...BASE, levels: 1 },
|
||||
"abcdef1234567890",
|
||||
0
|
||||
);
|
||||
expect(url).toBe(
|
||||
"https://chunks.example.com/0/abcdef1234567890_000000000.chunk"
|
||||
);
|
||||
});
|
||||
|
||||
it("levels=0 produces no directory prefix", { timeout: 5000 }, () => {
|
||||
const url = buildCdnChunkUrl({ ...BASE, levels: 0 }, "abcdef", 0);
|
||||
expect(url).toBe("https://chunks.example.com/abcdef_000000000.chunk");
|
||||
});
|
||||
|
||||
it("padding override affects chunk-id zero-padding", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const url = buildCdnChunkUrl(
|
||||
{ ...BASE, padding: 4 },
|
||||
"abcdef1234567890",
|
||||
12
|
||||
);
|
||||
expect(url).toBe(
|
||||
"https://chunks.example.com/0/9/8/abcdef1234567890_0012.chunk"
|
||||
);
|
||||
});
|
||||
|
||||
it("trailing slash on baseUrl is stripped", { timeout: 5000 }, () => {
|
||||
const url = buildCdnChunkUrl(
|
||||
{ ...BASE, baseUrl: "https://chunks.example.com/" },
|
||||
"abcdef1234567890",
|
||||
1
|
||||
);
|
||||
expect(url.startsWith("https://chunks.example.com/0/9/8/")).toBe(true);
|
||||
});
|
||||
|
||||
it("identical inputs produce byte-identical URLs", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = buildCdnChunkUrl(BASE, "abcdef", 99);
|
||||
const b = buildCdnChunkUrl(BASE, "abcdef", 99);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("rejects empty fileId", { timeout: 5000 }, () => {
|
||||
expect(() => buildCdnChunkUrl(BASE, "", 0)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects negative chunkId", { timeout: 5000 }, () => {
|
||||
expect(() => buildCdnChunkUrl(BASE, "abcdef", -1)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-integer chunkId", { timeout: 5000 }, () => {
|
||||
expect(() => buildCdnChunkUrl(BASE, "abcdef", 1.5)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects negative levels", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
buildCdnChunkUrl({ ...BASE, levels: -1 }, "abcdef", 0)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-integer levels", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
buildCdnChunkUrl({ ...BASE, levels: 1.5 }, "abcdef", 0)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects levels > fileId length", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
buildCdnChunkUrl({ ...BASE, levels: 10 }, "abc", 0)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects negative padding", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
buildCdnChunkUrl({ ...BASE, padding: -1 }, "abcdef", 0)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-integer padding", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
buildCdnChunkUrl({ ...BASE, padding: 1.5 }, "abcdef", 0)
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* DTP-19 — CdnUploadToken issuance and verification.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { CdnUploadToken } from "../src/index.js";
|
||||
import {
|
||||
canonicalSerializeUploadToken,
|
||||
type DriveTemplateError,
|
||||
DTP_CODES,
|
||||
signUploadToken,
|
||||
UPLOAD_TOKEN_TTL_DEFAULT_MS,
|
||||
UPLOAD_TOKEN_TTL_MAX_MS,
|
||||
verifyUploadToken,
|
||||
} from "../src/index.js";
|
||||
import { makeKeys } from "./_helpers.js";
|
||||
|
||||
const NOW = 2_000_000_000;
|
||||
|
||||
function baseToken(): Omit<CdnUploadToken, "serverSignature" | "expiresAt"> {
|
||||
return {
|
||||
fileId: "f".repeat(64),
|
||||
chunkId: 12,
|
||||
chunkHash: "blake3:abcdef",
|
||||
uploaderPubkey: "pubkey-hex",
|
||||
};
|
||||
}
|
||||
|
||||
describe("DTP-19 — CdnUploadToken", () => {
|
||||
it("default TTL pinned at 60s; max at 5m", { timeout: 5000 }, () => {
|
||||
expect(UPLOAD_TOKEN_TTL_DEFAULT_MS).toBe(60_000);
|
||||
expect(UPLOAD_TOKEN_TTL_MAX_MS).toBe(300_000);
|
||||
});
|
||||
|
||||
it("valid token accepted", { timeout: 5000 }, () => {
|
||||
const keys = makeKeys();
|
||||
const tok = signUploadToken(
|
||||
{ ...baseToken(), expiresAt: NOW + 30_000 },
|
||||
keys.secret,
|
||||
{ clock: () => NOW }
|
||||
);
|
||||
expect(verifyUploadToken(tok, keys.publicHex, () => NOW + 1_000)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("expired token rejected", { timeout: 5000 }, () => {
|
||||
const keys = makeKeys();
|
||||
const tok = signUploadToken(
|
||||
{ ...baseToken(), expiresAt: NOW + 30_000 },
|
||||
keys.secret,
|
||||
{ clock: () => NOW }
|
||||
);
|
||||
try {
|
||||
verifyUploadToken(tok, keys.publicHex, () => NOW + 100_000);
|
||||
throw new Error("expected expired");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.CDN_BAD_SIGNATURE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("tampered token rejected", { timeout: 5000 }, () => {
|
||||
const keys = makeKeys();
|
||||
const tok = signUploadToken(
|
||||
{ ...baseToken(), expiresAt: NOW + 30_000 },
|
||||
keys.secret,
|
||||
{ clock: () => NOW }
|
||||
);
|
||||
const tampered = { ...tok, chunkId: tok.chunkId + 1 };
|
||||
try {
|
||||
verifyUploadToken(tampered, keys.publicHex, () => NOW);
|
||||
throw new Error("expected sig failure");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.CDN_BAD_SIGNATURE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("token signed by wrong key rejected", { timeout: 5000 }, () => {
|
||||
const k1 = makeKeys();
|
||||
const k2 = makeKeys();
|
||||
const tok = signUploadToken(
|
||||
{ ...baseToken(), expiresAt: NOW + 30_000 },
|
||||
k1.secret,
|
||||
{ clock: () => NOW }
|
||||
);
|
||||
try {
|
||||
verifyUploadToken(tok, k2.publicHex, () => NOW);
|
||||
throw new Error("expected sig failure");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.CDN_BAD_SIGNATURE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("invalid server pubkey hex rejected", { timeout: 5000 }, () => {
|
||||
const keys = makeKeys();
|
||||
const tok = signUploadToken(
|
||||
{ ...baseToken(), expiresAt: NOW + 30_000 },
|
||||
keys.secret,
|
||||
{ clock: () => NOW }
|
||||
);
|
||||
try {
|
||||
verifyUploadToken(tok, "not-hex", () => NOW);
|
||||
throw new Error("expected pubkey rejection");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.CDN_BAD_SIGNATURE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("ttlMs out of range rejected at sign", { timeout: 5000 }, () => {
|
||||
const keys = makeKeys();
|
||||
expect(() =>
|
||||
signUploadToken({ ...baseToken(), expiresAt: 0 }, keys.secret, {
|
||||
clock: () => NOW,
|
||||
ttlMs: 0,
|
||||
})
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
signUploadToken({ ...baseToken(), expiresAt: 0 }, keys.secret, {
|
||||
clock: () => NOW,
|
||||
ttlMs: UPLOAD_TOKEN_TTL_MAX_MS + 1,
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("canonicalSerializeUploadToken is deterministic", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tok = { ...baseToken(), expiresAt: NOW + 30_000 };
|
||||
const a = canonicalSerializeUploadToken(tok);
|
||||
const b = canonicalSerializeUploadToken(tok);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it("default options use Date.now() as clock", { timeout: 5000 }, () => {
|
||||
const keys = makeKeys();
|
||||
const tok = signUploadToken(
|
||||
{ ...baseToken(), expiresAt: 0 },
|
||||
keys.secret
|
||||
);
|
||||
expect(tok.expiresAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("malformed signature buffer triggers verify catch arm", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const keys = makeKeys();
|
||||
const tok = signUploadToken(
|
||||
{ ...baseToken(), expiresAt: NOW + 30_000 },
|
||||
keys.secret,
|
||||
{ clock: () => NOW }
|
||||
);
|
||||
const broken = { ...tok, serverSignature: new Uint8Array(8) };
|
||||
try {
|
||||
verifyUploadToken(broken, keys.publicHex, () => NOW);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.CDN_BAD_SIGNATURE
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* DTP error-codes spec conformance — registry frozen, every code
|
||||
* usable to construct a `DriveTemplateError`, namespaced under `DTP.`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DriveTemplateError, DTP_CODES } from "../src/index.js";
|
||||
|
||||
describe("DTP_CODES is frozen", () => {
|
||||
it("DTP_CODES is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(DTP_CODES)).toBe(true);
|
||||
});
|
||||
|
||||
it("every value namespaced under DTP.", { timeout: 5000 }, () => {
|
||||
for (const v of Object.values(DTP_CODES)) {
|
||||
expect(v).toMatch(/^DTP\./);
|
||||
}
|
||||
});
|
||||
|
||||
it("every code constructs a DriveTemplateError that preserves it", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const code of Object.values(DTP_CODES)) {
|
||||
const e = new DriveTemplateError(code, "ok");
|
||||
expect(e.code).toBe(code);
|
||||
}
|
||||
});
|
||||
|
||||
it("registry contains the eight pipeline + two operational codes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const expected = [
|
||||
"TEMPLATE_VERSION_UNSUPPORTED",
|
||||
"TEMPLATE_EXPIRED",
|
||||
"TEMPLATE_STALE",
|
||||
"TEMPLATE_REPLAY",
|
||||
"TEMPLATE_UNTRUSTED_ISSUER",
|
||||
"TEMPLATE_SIG_INVALID",
|
||||
"TEMPLATE_TOO_DEEP",
|
||||
"TEMPLATE_MALFORMED",
|
||||
"CDN_BAD_SIGNATURE",
|
||||
"KEY_GRANT_REJECTED",
|
||||
] as const;
|
||||
for (const k of expected) {
|
||||
expect(DTP_CODES).toHaveProperty(k);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
import {
|
||||
DriveTemplateError,
|
||||
DTP_CODES,
|
||||
type DtpErrorCode,
|
||||
} from "../src/error-codes.js";
|
||||
|
||||
describe("@suaveplan/drive-template — DTP_CODES registry", () => {
|
||||
it("is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(DTP_CODES)).toBe(true);
|
||||
});
|
||||
|
||||
it("every code value starts with the DTP. namespace", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const value of Object.values(DTP_CODES)) {
|
||||
expect(value).toMatch(/^DTP\./);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes every documented rejection path", { timeout: 5000 }, () => {
|
||||
const expected = [
|
||||
"TEMPLATE_VERSION_UNSUPPORTED",
|
||||
"TEMPLATE_EXPIRED",
|
||||
"TEMPLATE_STALE",
|
||||
"TEMPLATE_REPLAY",
|
||||
"TEMPLATE_UNTRUSTED_ISSUER",
|
||||
"TEMPLATE_SIG_INVALID",
|
||||
"TEMPLATE_TOO_DEEP",
|
||||
"TEMPLATE_MALFORMED",
|
||||
"CDN_BAD_SIGNATURE",
|
||||
"KEY_GRANT_REJECTED",
|
||||
] as const;
|
||||
for (const key of expected) {
|
||||
expect(DTP_CODES).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/drive-template — DriveTemplateError class", () => {
|
||||
it("preserves the code", { timeout: 5000 }, () => {
|
||||
const err = new DriveTemplateError(
|
||||
DTP_CODES.TEMPLATE_EXPIRED,
|
||||
"test message"
|
||||
);
|
||||
expect(err.code).toBe(DTP_CODES.TEMPLATE_EXPIRED);
|
||||
expect(err.message).toBe("test message");
|
||||
});
|
||||
|
||||
it("threads cause and context through SuaveplanError options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new DriveTemplateError(
|
||||
DTP_CODES.TEMPLATE_SIG_INVALID,
|
||||
"bad sig",
|
||||
{
|
||||
cause,
|
||||
context: { issuer: "alice" },
|
||||
}
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ issuer: "alice" });
|
||||
});
|
||||
|
||||
it("permits all codes via DtpErrorCode union", { timeout: 5000 }, () => {
|
||||
const codes: DtpErrorCode[] = Object.values(DTP_CODES);
|
||||
expect(codes.length).toBeGreaterThanOrEqual(10);
|
||||
for (const code of codes) {
|
||||
const err = new DriveTemplateError(code, "ok");
|
||||
expect(err.code).toBe(code);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* DTP-21 — KeyGrantEnvelope validator (issuer / recipient / signature
|
||||
* / expiry checks in spec-mandated order).
|
||||
*/
|
||||
|
||||
import { sign } from "@suaveplan/crypto-curve/ed25519";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { KeyGrantPayload } from "../src/index.js";
|
||||
import {
|
||||
canonicalSerializeKeyGrant,
|
||||
type DriveTemplateError,
|
||||
DTP_CODES,
|
||||
validateKeyGrant,
|
||||
} from "../src/index.js";
|
||||
import { makeKeys } from "./_helpers.js";
|
||||
|
||||
const NOW = 2_000_000_000;
|
||||
|
||||
function makeGrant(
|
||||
issuerHex: string,
|
||||
recipientHex: string,
|
||||
issuerSecret: Uint8Array,
|
||||
overrides: Partial<KeyGrantPayload> = {}
|
||||
): KeyGrantPayload {
|
||||
const base: Omit<KeyGrantPayload, "issuerSignature"> = {
|
||||
fileId: "f".repeat(64),
|
||||
recipient: recipientHex,
|
||||
wrappedKey: new Uint8Array(48).fill(7),
|
||||
issuer: issuerHex,
|
||||
expiresAt: NOW + 60_000,
|
||||
...overrides,
|
||||
};
|
||||
const canonical = canonicalSerializeKeyGrant(base);
|
||||
const issuerSignature = sign(canonical, issuerSecret);
|
||||
return { ...base, issuerSignature, ...overrides };
|
||||
}
|
||||
|
||||
describe("DTP-21 — key-grant validator", () => {
|
||||
it("valid grant passes", { timeout: 5000 }, () => {
|
||||
const issuer = makeKeys();
|
||||
const recipient = makeKeys();
|
||||
const grant = makeGrant(
|
||||
issuer.publicHex,
|
||||
recipient.publicHex,
|
||||
issuer.secret
|
||||
);
|
||||
expect(
|
||||
validateKeyGrant(grant, {
|
||||
expectedKeyAuthority: issuer.publicHex,
|
||||
ourPubkey: recipient.publicHex,
|
||||
clock: () => NOW,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("issuer mismatch rejected (DMP-21 step 1)", { timeout: 5000 }, () => {
|
||||
const issuer = makeKeys();
|
||||
const recipient = makeKeys();
|
||||
const grant = makeGrant(
|
||||
issuer.publicHex,
|
||||
recipient.publicHex,
|
||||
issuer.secret
|
||||
);
|
||||
try {
|
||||
validateKeyGrant(grant, {
|
||||
expectedKeyAuthority: "different-authority",
|
||||
ourPubkey: recipient.publicHex,
|
||||
clock: () => NOW,
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.KEY_GRANT_REJECTED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("recipient mismatch rejected (step 2)", { timeout: 5000 }, () => {
|
||||
const issuer = makeKeys();
|
||||
const recipient = makeKeys();
|
||||
const someoneElse = makeKeys();
|
||||
const grant = makeGrant(
|
||||
issuer.publicHex,
|
||||
recipient.publicHex,
|
||||
issuer.secret
|
||||
);
|
||||
try {
|
||||
validateKeyGrant(grant, {
|
||||
expectedKeyAuthority: issuer.publicHex,
|
||||
ourPubkey: someoneElse.publicHex,
|
||||
clock: () => NOW,
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.KEY_GRANT_REJECTED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("invalid issuer hex rejected", { timeout: 5000 }, () => {
|
||||
const issuer = makeKeys();
|
||||
const recipient = makeKeys();
|
||||
const grant: KeyGrantPayload = {
|
||||
fileId: "f",
|
||||
recipient: recipient.publicHex,
|
||||
wrappedKey: new Uint8Array(8),
|
||||
issuer: "not-hex-zzz",
|
||||
issuerSignature: new Uint8Array(64),
|
||||
expiresAt: NOW + 60_000,
|
||||
};
|
||||
try {
|
||||
validateKeyGrant(grant, {
|
||||
expectedKeyAuthority: "not-hex-zzz",
|
||||
ourPubkey: recipient.publicHex,
|
||||
clock: () => NOW,
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.KEY_GRANT_REJECTED
|
||||
);
|
||||
}
|
||||
// Suppress unused-issuer lint
|
||||
void issuer;
|
||||
});
|
||||
|
||||
it("bad signature rejected (step 3)", { timeout: 5000 }, () => {
|
||||
const issuer = makeKeys();
|
||||
const recipient = makeKeys();
|
||||
const grant = makeGrant(
|
||||
issuer.publicHex,
|
||||
recipient.publicHex,
|
||||
issuer.secret
|
||||
);
|
||||
const tampered = {
|
||||
...grant,
|
||||
wrappedKey: new Uint8Array(48).fill(0xff),
|
||||
};
|
||||
try {
|
||||
validateKeyGrant(tampered, {
|
||||
expectedKeyAuthority: issuer.publicHex,
|
||||
ourPubkey: recipient.publicHex,
|
||||
clock: () => NOW,
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.KEY_GRANT_REJECTED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("expired grant rejected (step 4)", { timeout: 5000 }, () => {
|
||||
const issuer = makeKeys();
|
||||
const recipient = makeKeys();
|
||||
const grant = makeGrant(
|
||||
issuer.publicHex,
|
||||
recipient.publicHex,
|
||||
issuer.secret,
|
||||
{ expiresAt: NOW - 1 }
|
||||
);
|
||||
try {
|
||||
validateKeyGrant(grant, {
|
||||
expectedKeyAuthority: issuer.publicHex,
|
||||
ourPubkey: recipient.publicHex,
|
||||
clock: () => NOW,
|
||||
});
|
||||
throw new Error("expected expired");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.KEY_GRANT_REJECTED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("malformed signature buffer triggers verify catch arm", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const issuer = makeKeys();
|
||||
const recipient = makeKeys();
|
||||
const grant: KeyGrantPayload = {
|
||||
fileId: "f",
|
||||
recipient: recipient.publicHex,
|
||||
wrappedKey: new Uint8Array(8),
|
||||
issuer: issuer.publicHex,
|
||||
issuerSignature: new Uint8Array(8),
|
||||
};
|
||||
try {
|
||||
validateKeyGrant(grant, {
|
||||
expectedKeyAuthority: issuer.publicHex,
|
||||
ourPubkey: recipient.publicHex,
|
||||
clock: () => NOW,
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.KEY_GRANT_REJECTED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("grant without expiresAt accepted indefinitely", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const issuer = makeKeys();
|
||||
const recipient = makeKeys();
|
||||
// Build without expiresAt then re-sign over the same canonical bytes.
|
||||
const base: Omit<KeyGrantPayload, "issuerSignature"> = {
|
||||
fileId: "f".repeat(64),
|
||||
recipient: recipient.publicHex,
|
||||
wrappedKey: new Uint8Array(48).fill(7),
|
||||
issuer: issuer.publicHex,
|
||||
};
|
||||
const canonical = canonicalSerializeKeyGrant(base);
|
||||
const grant: KeyGrantPayload = {
|
||||
...base,
|
||||
issuerSignature: sign(canonical, issuer.secret),
|
||||
};
|
||||
expect(
|
||||
validateKeyGrant(grant, {
|
||||
expectedKeyAuthority: issuer.publicHex,
|
||||
ourPubkey: recipient.publicHex,
|
||||
clock: () => NOW,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* DTP-05 step 2 — TEMPLATE_EXPIRED rejection.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
type DriveTemplateError,
|
||||
DTP_CODES,
|
||||
TemplateNonceLru,
|
||||
validateTemplate,
|
||||
} from "../src/index.js";
|
||||
import { makeKeys, signed } from "./_helpers.js";
|
||||
|
||||
describe("DTP-05 step 2 — expired templates", () => {
|
||||
it("template past expiresAt rejected with TEMPLATE_EXPIRED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const keys = makeKeys();
|
||||
const NOW = 2_000_000_000;
|
||||
const tpl = signed(
|
||||
{
|
||||
issuer: keys.publicHex,
|
||||
issuedAt: NOW - 1_000,
|
||||
expiresAt: NOW - 1, // already expired
|
||||
},
|
||||
keys
|
||||
);
|
||||
const ctx = {
|
||||
clock: () => NOW,
|
||||
nonceLru: new TemplateNonceLru(),
|
||||
};
|
||||
try {
|
||||
validateTemplate(tpl, ctx);
|
||||
throw new Error("expected EXPIRED");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.TEMPLATE_EXPIRED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("template at exactly expiresAt rejected (>=)", { timeout: 5000 }, () => {
|
||||
const keys = makeKeys();
|
||||
const NOW = 2_000_000_000;
|
||||
const tpl = signed(
|
||||
{
|
||||
issuer: keys.publicHex,
|
||||
issuedAt: NOW - 1_000,
|
||||
expiresAt: NOW,
|
||||
},
|
||||
keys
|
||||
);
|
||||
const ctx = {
|
||||
clock: () => NOW,
|
||||
nonceLru: new TemplateNonceLru(),
|
||||
};
|
||||
try {
|
||||
validateTemplate(tpl, ctx);
|
||||
throw new Error("expected EXPIRED");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.TEMPLATE_EXPIRED
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* DTP-02 / DTP-09 / DTP-26 — schema-version pinning + per-file
|
||||
* consistency invariants + structured error context.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type {
|
||||
DriveTemplate,
|
||||
FileTemplate,
|
||||
FolderTemplate,
|
||||
} from "../src/index.js";
|
||||
import {
|
||||
assertFile,
|
||||
assertFolderFileInvariants,
|
||||
type DriveTemplateError,
|
||||
DTP_CODES,
|
||||
TemplateNonceLru,
|
||||
validateTemplate,
|
||||
} from "../src/index.js";
|
||||
import { DEFAULT_POLICY, makeKeys, signed } from "./_helpers.js";
|
||||
|
||||
const baseFile: FileTemplate = {
|
||||
name: "x.bin",
|
||||
fileLocId: "loc-1",
|
||||
fileId: "f".repeat(64),
|
||||
size: 100,
|
||||
totalChunks: 1,
|
||||
chunkSize: 1024,
|
||||
checksum: "blake3:abcd",
|
||||
flags: DEFAULT_POLICY,
|
||||
manifestKey: "manifest:f",
|
||||
manifestSig: new Uint8Array(64).fill(1),
|
||||
};
|
||||
|
||||
describe("DTP-02 — schema version pinned to 1", () => {
|
||||
it("schemaVersion ≠ 1 rejected with TEMPLATE_VERSION_UNSUPPORTED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const keys = makeKeys();
|
||||
const tpl = signed({ issuer: keys.publicHex }, keys);
|
||||
// Cast through unknown to bypass the literal-1 type guard.
|
||||
const broken = { ...tpl, schemaVersion: 2 } as unknown as DriveTemplate;
|
||||
try {
|
||||
validateTemplate(broken, {
|
||||
clock: () => Date.now(),
|
||||
nonceLru: new TemplateNonceLru(),
|
||||
});
|
||||
throw new Error("expected VERSION_UNSUPPORTED");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.TEMPLATE_VERSION_UNSUPPORTED
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DTP-09 — per-file consistency invariants", () => {
|
||||
it("chunkSize 164 rejected", { timeout: 5000 }, () => {
|
||||
try {
|
||||
assertFile({ ...baseFile, chunkSize: 164 }, "/x.bin");
|
||||
throw new Error("expected MALFORMED");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.TEMPLATE_MALFORMED
|
||||
);
|
||||
expect((err as DriveTemplateError).context).toMatchObject({
|
||||
file: "/x.bin",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("totalChunks insufficient for size rejected", { timeout: 5000 }, () => {
|
||||
try {
|
||||
assertFile(
|
||||
{ ...baseFile, totalChunks: 1, chunkSize: 1024, size: 4096 },
|
||||
"/x.bin"
|
||||
);
|
||||
throw new Error("expected MALFORMED");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.TEMPLATE_MALFORMED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("valid file passes through assertFile (positive path)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => assertFile(baseFile, "/x.bin")).not.toThrow();
|
||||
});
|
||||
|
||||
it("empty manifestKey rejected", { timeout: 5000 }, () => {
|
||||
try {
|
||||
assertFile({ ...baseFile, manifestKey: "" }, "/x.bin");
|
||||
throw new Error("expected MALFORMED");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.TEMPLATE_MALFORMED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("malformed checksum rejected", { timeout: 5000 }, () => {
|
||||
try {
|
||||
assertFile({ ...baseFile, checksum: "md5:abc" }, "/x.bin");
|
||||
throw new Error("expected MALFORMED");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.TEMPLATE_MALFORMED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("empty manifestSig rejected", { timeout: 5000 }, () => {
|
||||
try {
|
||||
assertFile(
|
||||
{ ...baseFile, manifestSig: new Uint8Array(0) },
|
||||
"/x.bin"
|
||||
);
|
||||
throw new Error("expected MALFORMED");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.TEMPLATE_MALFORMED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("nested folder: deepest file's failure surfaces with full path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tree: FolderTemplate = {
|
||||
name: "/",
|
||||
children: [
|
||||
{
|
||||
name: "photos",
|
||||
children: [
|
||||
{
|
||||
name: "wedding",
|
||||
children: [{ ...baseFile, chunkSize: 164 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
try {
|
||||
assertFolderFileInvariants(tree);
|
||||
throw new Error("expected MALFORMED");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.TEMPLATE_MALFORMED
|
||||
);
|
||||
const ctx = (err as DriveTemplateError).context as { file: string };
|
||||
expect(ctx.file).toContain("photos");
|
||||
expect(ctx.file).toContain("wedding");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* DTP-06 — replay protection via per-client nonce LRU.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
type DriveTemplateError,
|
||||
DTP_CODES,
|
||||
TEMPLATE_NONCE_LRU_DEFAULT_CAPACITY,
|
||||
TemplateNonceLru,
|
||||
validateTemplate,
|
||||
} from "../src/index.js";
|
||||
import { makeKeys, makeNonce, signed } from "./_helpers.js";
|
||||
|
||||
describe("DTP-06 — replay protection", () => {
|
||||
it("hydrate with same (driveId, nonce) twice rejects on second call", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const keys = makeKeys();
|
||||
const lru = new TemplateNonceLru();
|
||||
const tpl = signed(
|
||||
{
|
||||
issuer: keys.publicHex,
|
||||
nonce: makeNonce(0x42),
|
||||
},
|
||||
keys
|
||||
);
|
||||
const ctx = { clock: () => Date.now(), nonceLru: lru };
|
||||
validateTemplate(tpl, ctx);
|
||||
try {
|
||||
validateTemplate(tpl, ctx);
|
||||
throw new Error("expected REPLAY rejection");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.TEMPLATE_REPLAY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("LRU capacity is the documented default", { timeout: 5000 }, () => {
|
||||
expect(TEMPLATE_NONCE_LRU_DEFAULT_CAPACITY).toBe(1024);
|
||||
});
|
||||
|
||||
it("LRU evicts oldest entry past capacity", { timeout: 5000 }, () => {
|
||||
const lru = new TemplateNonceLru(2);
|
||||
const a = makeNonce(1);
|
||||
const b = makeNonce(2);
|
||||
const c = makeNonce(3);
|
||||
lru.remember("d", a);
|
||||
lru.remember("d", b);
|
||||
lru.remember("d", c);
|
||||
expect(lru.has("d", a)).toBe(false);
|
||||
expect(lru.has("d", b)).toBe(true);
|
||||
expect(lru.has("d", c)).toBe(true);
|
||||
expect(lru.size).toBe(2);
|
||||
});
|
||||
|
||||
it("LRU reinserts on duplicate to refresh recency", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const lru = new TemplateNonceLru(2);
|
||||
const a = makeNonce(1);
|
||||
const b = makeNonce(2);
|
||||
lru.remember("d", a);
|
||||
lru.remember("d", b);
|
||||
lru.remember("d", a); // refresh
|
||||
const c = makeNonce(3);
|
||||
lru.remember("d", c); // evicts b, not a
|
||||
expect(lru.has("d", a)).toBe(true);
|
||||
expect(lru.has("d", b)).toBe(false);
|
||||
expect(lru.has("d", c)).toBe(true);
|
||||
});
|
||||
|
||||
it("LRU rejects non-positive capacity", { timeout: 5000 }, () => {
|
||||
expect(() => new TemplateNonceLru(0)).toThrow();
|
||||
expect(() => new TemplateNonceLru(-1)).toThrow();
|
||||
expect(() => new TemplateNonceLru(1.5)).toThrow();
|
||||
});
|
||||
|
||||
it("clear() empties the LRU", { timeout: 5000 }, () => {
|
||||
const lru = new TemplateNonceLru(8);
|
||||
lru.remember("d", makeNonce(1));
|
||||
expect(lru.size).toBe(1);
|
||||
lru.clear();
|
||||
expect(lru.size).toBe(0);
|
||||
});
|
||||
|
||||
it("capacity getter exposes the configured cap", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const lru = new TemplateNonceLru(99);
|
||||
expect(lru.capacity).toBe(99);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* DTP-01 / DTP-03 — DriveTemplate shape + canonical serialization
|
||||
* deterministic across calls.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { canonicalSerialize } from "../src/codec/canonical.js";
|
||||
import type { FileTemplate, FolderTemplate } from "../src/index.js";
|
||||
import { signTemplate, verifyTemplateSignature } from "../src/index.js";
|
||||
import { buildTemplate, DEFAULT_POLICY, makeKeys } from "./_helpers.js";
|
||||
|
||||
const FILE: FileTemplate = {
|
||||
name: "photo.jpg",
|
||||
fileLocId: "loc-1",
|
||||
fileId: "f".repeat(64),
|
||||
size: 1024,
|
||||
totalChunks: 1,
|
||||
chunkSize: 1024,
|
||||
checksum: "blake3:abcd",
|
||||
flags: DEFAULT_POLICY,
|
||||
manifestKey: "manifest:f",
|
||||
manifestSig: new Uint8Array(64).fill(1),
|
||||
};
|
||||
|
||||
const ROOT: FolderTemplate = {
|
||||
name: "/",
|
||||
children: [{ name: "photos", children: [FILE] }],
|
||||
};
|
||||
|
||||
describe("DTP-01 — DriveTemplate shape", () => {
|
||||
it("DriveTemplate with all required fields encodes/decodes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const keys = makeKeys();
|
||||
const tpl = signTemplate(
|
||||
buildTemplate({ issuer: keys.publicHex, rootFolder: ROOT }),
|
||||
keys.secret
|
||||
);
|
||||
expect(verifyTemplateSignature(tpl)).toBe(true);
|
||||
});
|
||||
|
||||
it("Minimal empty-rootFolder template signs and verifies", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const keys = makeKeys();
|
||||
const tpl = signTemplate(
|
||||
buildTemplate({ issuer: keys.publicHex }),
|
||||
keys.secret
|
||||
);
|
||||
expect(verifyTemplateSignature(tpl)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DTP-03 — canonicalSerialize is deterministic", () => {
|
||||
it("canonicalSerialize is deterministic across calls", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const keys = makeKeys();
|
||||
const tpl = buildTemplate({ issuer: keys.publicHex, rootFolder: ROOT });
|
||||
const a = canonicalSerialize(tpl);
|
||||
const b = canonicalSerialize(tpl);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it("Field-reorder produces identical bytes", { timeout: 5000 }, () => {
|
||||
const keys = makeKeys();
|
||||
const a = buildTemplate({ issuer: keys.publicHex, rootFolder: ROOT });
|
||||
// Recreate with fields in a different in-memory order — the
|
||||
// canonical encoder's fixed schema-driven order means the bytes
|
||||
// are still identical.
|
||||
const reordered = {
|
||||
policy: a.policy,
|
||||
schemaVersion: a.schemaVersion,
|
||||
issuedAt: a.issuedAt,
|
||||
issuer: a.issuer,
|
||||
driveId: a.driveId,
|
||||
expiresAt: a.expiresAt,
|
||||
nonce: a.nonce,
|
||||
rootFolder: a.rootFolder,
|
||||
} as typeof a;
|
||||
expect(canonicalSerialize(a)).toEqual(canonicalSerialize(reordered));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* DTP-05 step 3 / DTP-07 — issuance window enforcement.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
type DriveTemplateError,
|
||||
DTP_CODES,
|
||||
ISSUANCE_WINDOW_MS,
|
||||
TemplateNonceLru,
|
||||
validateTemplate,
|
||||
} from "../src/index.js";
|
||||
import { makeKeys, signed } from "./_helpers.js";
|
||||
|
||||
describe("DTP-07 — 24-hour issuance window", () => {
|
||||
it("ISSUANCE_WINDOW_MS pinned at 24h", { timeout: 5000 }, () => {
|
||||
expect(ISSUANCE_WINDOW_MS).toBe(24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("template issued 24h+1s ago rejected as STALE", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const keys = makeKeys();
|
||||
const NOW = 5_000_000_000;
|
||||
const issuedAt = NOW - ISSUANCE_WINDOW_MS - 1_000;
|
||||
const tpl = signed(
|
||||
{
|
||||
issuer: keys.publicHex,
|
||||
issuedAt,
|
||||
expiresAt: NOW + 60_000,
|
||||
},
|
||||
keys
|
||||
);
|
||||
const ctx = {
|
||||
clock: () => NOW,
|
||||
nonceLru: new TemplateNonceLru(),
|
||||
};
|
||||
try {
|
||||
validateTemplate(tpl, ctx);
|
||||
throw new Error("expected STALE");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.TEMPLATE_STALE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("template issued just under 24h ago accepted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const keys = makeKeys();
|
||||
const NOW = 5_000_000_000;
|
||||
const issuedAt = NOW - ISSUANCE_WINDOW_MS + 1_000;
|
||||
const tpl = signed(
|
||||
{
|
||||
issuer: keys.publicHex,
|
||||
issuedAt,
|
||||
expiresAt: NOW + 60_000,
|
||||
},
|
||||
keys
|
||||
);
|
||||
const ctx = {
|
||||
clock: () => NOW,
|
||||
nonceLru: new TemplateNonceLru(),
|
||||
};
|
||||
expect(() => validateTemplate(tpl, ctx)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* DTP-04 — single bit flip in any field causes signature verification
|
||||
* to fail. Property test using fast-check picks a random byte position
|
||||
* inside the signed canonical bytes and flips one bit; verification
|
||||
* MUST throw `DRIVE.TEMPLATE_SIG_INVALID`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { canonicalSerialize } from "../src/codec/canonical.js";
|
||||
import {
|
||||
DriveTemplateError,
|
||||
DTP_CODES,
|
||||
signTemplate,
|
||||
verifyTemplateSignature,
|
||||
} from "../src/index.js";
|
||||
import { buildTemplate, makeKeys, makeNonce } from "./_helpers.js";
|
||||
|
||||
describe("DTP-04 — server signature covers every field", () => {
|
||||
it("single bit flip in any field causes signature failure", {
|
||||
timeout: 10_000,
|
||||
}, () => {
|
||||
const keys = makeKeys();
|
||||
const tpl = signTemplate(
|
||||
buildTemplate({ issuer: keys.publicHex }),
|
||||
keys.secret
|
||||
);
|
||||
expect(verifyTemplateSignature(tpl)).toBe(true);
|
||||
// Property: for any byte position inside the canonical bytes,
|
||||
// a flipped bit must make verification fail.
|
||||
const canonical = canonicalSerialize(tpl);
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 0, max: canonical.length - 1 }),
|
||||
fc.integer({ min: 0, max: 7 }),
|
||||
(bytePos, bitPos) => {
|
||||
// Flip the bit in the nonce field (byte index inside nonce).
|
||||
const flippedNonce = new Uint8Array(tpl.nonce);
|
||||
if (bytePos < flippedNonce.length) {
|
||||
flippedNonce[bytePos] =
|
||||
(flippedNonce[bytePos] ?? 0) ^ (1 << bitPos);
|
||||
const tampered = { ...tpl, nonce: flippedNonce };
|
||||
try {
|
||||
verifyTemplateSignature(tampered);
|
||||
return false; // verification should not pass
|
||||
} catch (err) {
|
||||
return (
|
||||
err instanceof DriveTemplateError &&
|
||||
err.code === DTP_CODES.TEMPLATE_SIG_INVALID
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
|
||||
it("flipping issuedAt fails verification", { timeout: 5000 }, () => {
|
||||
const keys = makeKeys();
|
||||
const tpl = signTemplate(
|
||||
buildTemplate({ issuer: keys.publicHex }),
|
||||
keys.secret
|
||||
);
|
||||
const tampered = { ...tpl, issuedAt: tpl.issuedAt + 1 };
|
||||
try {
|
||||
verifyTemplateSignature(tampered);
|
||||
throw new Error("expected sig failure");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.TEMPLATE_SIG_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("flipping nonce fails verification", { timeout: 5000 }, () => {
|
||||
const keys = makeKeys();
|
||||
const tpl = signTemplate(
|
||||
buildTemplate({ issuer: keys.publicHex, nonce: makeNonce(0xab) }),
|
||||
keys.secret
|
||||
);
|
||||
const tampered = { ...tpl, nonce: makeNonce(0xcd) };
|
||||
try {
|
||||
verifyTemplateSignature(tampered);
|
||||
throw new Error("expected sig failure");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.TEMPLATE_SIG_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("verification under wrong issuer key fails", { timeout: 5000 }, () => {
|
||||
const k1 = makeKeys();
|
||||
const k2 = makeKeys();
|
||||
const tpl = signTemplate(
|
||||
buildTemplate({ issuer: k1.publicHex }),
|
||||
k1.secret
|
||||
);
|
||||
const wrongIssuerTpl = { ...tpl, issuer: k2.publicHex };
|
||||
try {
|
||||
verifyTemplateSignature(wrongIssuerTpl);
|
||||
throw new Error("expected sig failure");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.TEMPLATE_SIG_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("invalid issuer hex rejected with TEMPLATE_SIG_INVALID", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const keys = makeKeys();
|
||||
const tpl = signTemplate(
|
||||
buildTemplate({ issuer: keys.publicHex }),
|
||||
keys.secret
|
||||
);
|
||||
const broken = { ...tpl, issuer: "not-hex-zzz" };
|
||||
try {
|
||||
verifyTemplateSignature(broken);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.TEMPLATE_SIG_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("malformed signature buffer (wrong length) rejected via catch arm", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const keys = makeKeys();
|
||||
const tpl = signTemplate(
|
||||
buildTemplate({ issuer: keys.publicHex }),
|
||||
keys.secret
|
||||
);
|
||||
const broken = {
|
||||
...tpl,
|
||||
serverSignature: new Uint8Array(8),
|
||||
};
|
||||
try {
|
||||
verifyTemplateSignature(broken);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.TEMPLATE_SIG_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* DTP-08 — folder depth cap.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { FolderTemplate } from "../src/index.js";
|
||||
import {
|
||||
assertFolderDepth,
|
||||
type DriveTemplateError,
|
||||
DTP_CODES,
|
||||
MAX_FOLDER_DEPTH,
|
||||
} from "../src/index.js";
|
||||
|
||||
function chain(depth: number): FolderTemplate {
|
||||
let node: FolderTemplate = { name: `level-${depth}`, children: [] };
|
||||
for (let i = depth - 1; i >= 0; i--) {
|
||||
node = { name: `level-${i}`, children: [node] };
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
describe("DTP-08 — folder depth cap", () => {
|
||||
it("MAX_FOLDER_DEPTH pinned at 32", { timeout: 5000 }, () => {
|
||||
expect(MAX_FOLDER_DEPTH).toBe(32);
|
||||
});
|
||||
|
||||
it("depth-32 nested folder accepted", { timeout: 5000 }, () => {
|
||||
const tree = chain(32);
|
||||
expect(() => assertFolderDepth(tree)).not.toThrow();
|
||||
});
|
||||
|
||||
it("depth-33 nested folder rejected", { timeout: 5000 }, () => {
|
||||
const tree = chain(33);
|
||||
try {
|
||||
assertFolderDepth(tree);
|
||||
throw new Error("expected TOO_DEEP");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.TEMPLATE_TOO_DEEP
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* DTP-05 step 5 — TEMPLATE_UNTRUSTED_ISSUER rejection.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
type DriveTemplateError,
|
||||
DTP_CODES,
|
||||
signTemplate,
|
||||
TemplateNonceLru,
|
||||
validateTemplate,
|
||||
} from "../src/index.js";
|
||||
import { buildTemplate, emptyRoot, makeKeys } from "./_helpers.js";
|
||||
|
||||
describe("DTP-05 step 5 — trusted issuer gate", () => {
|
||||
it("issuer not in trustedKeyAuthorities rejected", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const keys = makeKeys();
|
||||
const tpl = signTemplate(
|
||||
{
|
||||
...buildTemplate({ issuer: keys.publicHex }),
|
||||
policy: {
|
||||
defaultPolicy: {
|
||||
autoDownload: false,
|
||||
meshAllowed: true,
|
||||
cdnAllowed: true,
|
||||
},
|
||||
trustedKeyAuthorities: ["someone-else"],
|
||||
},
|
||||
},
|
||||
keys.secret
|
||||
);
|
||||
const ctx = {
|
||||
clock: () => Date.now(),
|
||||
nonceLru: new TemplateNonceLru(),
|
||||
};
|
||||
try {
|
||||
validateTemplate(tpl, ctx);
|
||||
throw new Error("expected UNTRUSTED_ISSUER");
|
||||
} catch (err) {
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.TEMPLATE_UNTRUSTED_ISSUER
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("empty trustedKeyAuthorities permits any issuer (no allowlist)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const keys = makeKeys();
|
||||
const tpl = signTemplate(
|
||||
{
|
||||
...buildTemplate({ issuer: keys.publicHex }),
|
||||
policy: {
|
||||
defaultPolicy: {
|
||||
autoDownload: false,
|
||||
meshAllowed: true,
|
||||
cdnAllowed: true,
|
||||
},
|
||||
trustedKeyAuthorities: [],
|
||||
},
|
||||
},
|
||||
keys.secret
|
||||
);
|
||||
const ctx = {
|
||||
clock: () => Date.now(),
|
||||
nonceLru: new TemplateNonceLru(),
|
||||
};
|
||||
expect(() => validateTemplate(tpl, ctx)).not.toThrow();
|
||||
});
|
||||
|
||||
it("issuer in allowlist accepted", { timeout: 5000 }, () => {
|
||||
const keys = makeKeys();
|
||||
const tpl = signTemplate(
|
||||
{
|
||||
...buildTemplate({
|
||||
issuer: keys.publicHex,
|
||||
rootFolder: emptyRoot(),
|
||||
}),
|
||||
policy: {
|
||||
defaultPolicy: {
|
||||
autoDownload: false,
|
||||
meshAllowed: true,
|
||||
cdnAllowed: true,
|
||||
},
|
||||
trustedKeyAuthorities: [keys.publicHex],
|
||||
},
|
||||
},
|
||||
keys.secret
|
||||
);
|
||||
const ctx = {
|
||||
clock: () => Date.now(),
|
||||
nonceLru: new TemplateNonceLru(),
|
||||
};
|
||||
expect(() => validateTemplate(tpl, ctx)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Forward-defensive catch arms in `codec/verify`, `key/grant-validator`,
|
||||
* and `cdn/upload-token` — exercised here by injecting a throwing
|
||||
* `verifyFn` so the catch path runs in production code rather than
|
||||
* being papered over with `istanbul ignore` annotations. Per AGENTS.md
|
||||
* §7a, defensive guards must have a real test exercising them.
|
||||
*/
|
||||
|
||||
import { sign } from "@suaveplan/crypto-curve/ed25519";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { KeyGrantPayload } from "../src/index.js";
|
||||
import {
|
||||
canonicalSerializeKeyGrant,
|
||||
DriveTemplateError,
|
||||
DTP_CODES,
|
||||
signTemplate,
|
||||
signUploadToken,
|
||||
validateKeyGrant,
|
||||
verifyTemplateSignature,
|
||||
verifyUploadToken,
|
||||
} from "../src/index.js";
|
||||
import { buildTemplate, makeKeys } from "./_helpers.js";
|
||||
|
||||
const NOW = 2_000_000_000;
|
||||
|
||||
function throwingVerify(): never {
|
||||
throw new Error("synthetic verify failure");
|
||||
}
|
||||
|
||||
describe("AGENTS §7a — verify catch arms", () => {
|
||||
it("verifyTemplateSignature surfaces a thrown verifier as TEMPLATE_SIG_INVALID", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const keys = makeKeys();
|
||||
const tpl = signTemplate(
|
||||
buildTemplate({ issuer: keys.publicHex }),
|
||||
keys.secret
|
||||
);
|
||||
try {
|
||||
verifyTemplateSignature(tpl, throwingVerify);
|
||||
throw new Error("expected SIG_INVALID");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DriveTemplateError);
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.TEMPLATE_SIG_INVALID
|
||||
);
|
||||
expect((err as DriveTemplateError).message).toContain(
|
||||
"synthetic verify failure"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("validateKeyGrant surfaces a thrown verifier as KEY_GRANT_REJECTED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const issuer = makeKeys();
|
||||
const recipient = makeKeys();
|
||||
const base: Omit<KeyGrantPayload, "issuerSignature"> = {
|
||||
fileId: "f".repeat(64),
|
||||
recipient: recipient.publicHex,
|
||||
wrappedKey: new Uint8Array(48).fill(7),
|
||||
issuer: issuer.publicHex,
|
||||
expiresAt: NOW + 60_000,
|
||||
};
|
||||
const canonical = canonicalSerializeKeyGrant(base);
|
||||
const grant: KeyGrantPayload = {
|
||||
...base,
|
||||
issuerSignature: sign(canonical, issuer.secret),
|
||||
};
|
||||
try {
|
||||
validateKeyGrant(grant, {
|
||||
expectedKeyAuthority: issuer.publicHex,
|
||||
ourPubkey: recipient.publicHex,
|
||||
clock: () => NOW,
|
||||
verifyFn: throwingVerify,
|
||||
});
|
||||
throw new Error("expected KEY_GRANT_REJECTED");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DriveTemplateError);
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.KEY_GRANT_REJECTED
|
||||
);
|
||||
expect((err as DriveTemplateError).message).toContain(
|
||||
"synthetic verify failure"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("verifyUploadToken surfaces a thrown verifier as CDN_BAD_SIGNATURE", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const keys = makeKeys();
|
||||
const tok = signUploadToken(
|
||||
{
|
||||
fileId: "f".repeat(64),
|
||||
chunkId: 0,
|
||||
chunkHash: "blake3:abc",
|
||||
uploaderPubkey: "uploader",
|
||||
expiresAt: NOW + 30_000,
|
||||
},
|
||||
keys.secret,
|
||||
{ clock: () => NOW }
|
||||
);
|
||||
try {
|
||||
verifyUploadToken(tok, keys.publicHex, () => NOW, throwingVerify);
|
||||
throw new Error("expected CDN_BAD_SIGNATURE");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DriveTemplateError);
|
||||
expect((err as DriveTemplateError).code).toBe(
|
||||
DTP_CODES.CDN_BAD_SIGNATURE
|
||||
);
|
||||
expect((err as DriveTemplateError).message).toContain(
|
||||
"synthetic verify failure"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user