Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"test": "vitest run",
"db:generate": "drizzle-kit generate",
"db:migrate:local": "wrangler d1 migrations apply trust0-db --local",
"db:migrate:remote": "wrangler d1 migrations apply trust0-db --remote"
Expand All @@ -21,6 +22,7 @@
"devDependencies": {
"@cloudflare/workers-types": "^4.20241230.0",
"drizzle-kit": "^0.30.0",
"vitest": "^4.0.0",
"wrangler": "^4.14.0"
}
}
136 changes: 136 additions & 0 deletions apps/api/src/__tests__/policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { computeLinkHash, createChainLink, generateIdentityKey, verifyChain } from "@trust0/identity";
import { describe, expect, it } from "vitest";
import {
assertAppendAuthorized,
assertProfileMatchesChainState,
classifyProfileUpdate,
} from "../policy";

async function makeIdentity() {
const { privateKey, publicJWK } = await generateIdentityKey();
const { computeFingerprint } = await import("@trust0/identity");
const fingerprint = await computeFingerprint(publicJWK);
return { privateKey, publicJWK, fingerprint };
}

async function buildRotatedState() {
const key1 = await makeIdentity();
const key2 = await makeIdentity();

const genesis = await createChainLink({
seqno: 0,
prev: null,
type: "key_init",
body: { fingerprint: key1.fingerprint },
key: key1.privateKey,
publicJWK: key1.publicJWK,
fingerprint: key1.fingerprint,
});

const rotate = await createChainLink({
seqno: 1,
prev: await computeLinkHash(genesis),
type: "key_rotate",
body: { new_fingerprint: key2.fingerprint },
key: key1.privateKey,
publicJWK: key1.publicJWK,
fingerprint: key1.fingerprint,
});

const update = await createChainLink({
seqno: 2,
prev: await computeLinkHash(rotate),
type: "profile_update",
body: { profile_fingerprint: key2.fingerprint },
key: key2.privateKey,
publicJWK: key2.publicJWK,
fingerprint: key2.fingerprint,
});

const state = await verifyChain([genesis, rotate, update]);
return { key1, key2, state };
}

async function buildRevokedState() {
const key1 = await makeIdentity();
const key2 = await makeIdentity();

const genesis = await createChainLink({
seqno: 0,
prev: null,
type: "key_init",
body: { fingerprint: key1.fingerprint },
key: key1.privateKey,
publicJWK: key1.publicJWK,
fingerprint: key1.fingerprint,
});

const rotate = await createChainLink({
seqno: 1,
prev: await computeLinkHash(genesis),
type: "key_rotate",
body: { new_fingerprint: key2.fingerprint },
key: key1.privateKey,
publicJWK: key1.publicJWK,
fingerprint: key1.fingerprint,
});

const update = await createChainLink({
seqno: 2,
prev: await computeLinkHash(rotate),
type: "profile_update",
body: { profile_fingerprint: key2.fingerprint },
key: key2.privateKey,
publicJWK: key2.publicJWK,
fingerprint: key2.fingerprint,
});

const revoke = await createChainLink({
seqno: 3,
prev: await computeLinkHash(update),
type: "key_revoke",
body: { fingerprint: key1.fingerprint },
key: key2.privateKey,
publicJWK: key2.publicJWK,
fingerprint: key2.fingerprint,
});

const state = await verifyChain([genesis, rotate, update, revoke]);
return { key1, key2, state };
}

describe("api policy", () => {
it("allows appends from a rotated active key", async () => {
const { key2, state } = await buildRotatedState();
expect(() => assertAppendAuthorized(state, key2.fingerprint)).not.toThrow();
});

it("rejects appends from an inactive key", async () => {
const { key1, state } = await buildRevokedState();
expect(() => assertAppendAuthorized(state, key1.fingerprint)).toThrow(
"Signer is not an active key for this identity",
);
});

it("classifies a rotated-key profile update as valid", async () => {
const { key1, key2, state } = await buildRotatedState();
expect(classifyProfileUpdate(state, key1.fingerprint, key2.fingerprint)).toBe(
"rotated-key",
);
});

it("rejects a rotated-key profile update from an inactive signer", async () => {
const { key1, state } = await buildRotatedState();
const outsider = await makeIdentity();
expect(() =>
classifyProfileUpdate(state, key1.fingerprint, outsider.fingerprint),
).toThrow("Signer is not an active key for this identity");
});

it("rejects imported/current profiles that do not match chain state", async () => {
const { key1, state } = await buildRotatedState();
expect(() => assertProfileMatchesChainState(state, key1.fingerprint)).toThrow(
"Profile fingerprint does not match the sigchain's current profile",
);
});
});
92 changes: 82 additions & 10 deletions apps/api/src/aspe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,29 @@ import { and, eq } from "drizzle-orm";
import { Hono } from "hono";
import { cors } from "hono/cors";
import * as schema from "./db/schema";
import {
loadVerifiedChainState,
} from "./identity-state";
import {
type AuthEnv,
requireAuth,
sessionMiddleware,
} from "./middleware/session";
import {
assertProfileMatchesChainState,
classifyProfileUpdate,
} from "./policy";

const aspe = new Hono<AuthEnv>();

const aspeUriFor = (domain: string | undefined, fingerprint: string): string =>
`aspe:${domain || "trust0.app"}:${fingerprint}`;

const parseAspeUriFingerprint = (aspeUri: string): string | null => {
const match = aspeUri.match(/^aspe:[^:]+:([A-Z2-7]+)$/i);
return match?.[1]?.toUpperCase() ?? null;
};

// SPEC EXTENSION (APC-005): Ariadne spec does not mention CORS.
// Browser-based ASPE clients need CORS headers for cross-origin fetch.
// See: dev/spec/proposed-changes.md#apc-005
Expand Down Expand Up @@ -75,7 +90,7 @@ aspe.post("/.well-known/aspe/post/", requireAuth, async (c) => {
return c.json({ error: `Invalid request: ${(err as Error).message}` }, 400);
}

const { action, fingerprint, profileJws } = request;
const { action, fingerprint, profileJws, aspeUri } = request;
const now = new Date();

if (action === "create") {
Expand Down Expand Up @@ -127,13 +142,16 @@ aspe.post("/.well-known/aspe/post/", requireAuth, async (c) => {
updatedAt: now,
});

return c.json({ fingerprint, uri: `aspe:${fingerprint}` }, 201);
return c.json({ fingerprint, uri: aspeUriFor(c.env.ASPE_DOMAIN, fingerprint) }, 201);
}

if (action === "update") {
if (!profileJws) {
return c.json({ error: "profile_jws required for update" }, 400);
}
if (!aspeUri) {
return c.json({ error: "aspe_uri required for update" }, 400);
}

try {
const profile = await parseProfile(profileJws);
Expand All @@ -147,10 +165,15 @@ aspe.post("/.well-known/aspe/post/", requireAuth, async (c) => {
);
}

const targetFingerprint = parseAspeUriFingerprint(aspeUri);
if (!targetFingerprint) {
return c.json({ error: "Invalid aspe_uri" }, 400);
}

const [existing] = await db
.select()
.from(schema.cryptoProfile)
.where(eq(schema.cryptoProfile.fingerprint, fingerprint))
.where(eq(schema.cryptoProfile.fingerprint, targetFingerprint))
.limit(1);

if (!existing) {
Expand All @@ -161,13 +184,62 @@ aspe.post("/.well-known/aspe/post/", requireAuth, async (c) => {
return c.json({ error: "Not authorized to update this profile" }, 403);
}

await db
.update(schema.cryptoProfile)
.set({ profileJws, updatedAt: now })
.where(eq(schema.cryptoProfile.fingerprint, fingerprint));
if (existing.fingerprint === fingerprint) {
await db
.update(schema.cryptoProfile)
.set({ profileJws, updatedAt: now })
.where(eq(schema.cryptoProfile.fingerprint, existing.fingerprint));
} else {
if (!existing.identityId) {
return c.json(
{ error: "Cannot rotate profile keys before the sigchain is initialized" },
400,
);
}

return c.json({ fingerprint, uri: `aspe:${fingerprint}` });
}
const { state } = await loadVerifiedChainState(db, existing.identityId);
try {
classifyProfileUpdate(state, existing.fingerprint, fingerprint);
} catch (err) {
return c.json({ error: (err as Error).message }, 403);
}

const [conflictingProfile] = await db
.select()
.from(schema.cryptoProfile)
.where(eq(schema.cryptoProfile.fingerprint, fingerprint))
.limit(1);

if (conflictingProfile) {
return c.json({ error: "A profile already exists for the new fingerprint" }, 409);
}

await db.insert(schema.cryptoProfile).values({
fingerprint,
profileJws,
userId: user.id,
identityId: existing.identityId,
createdAt: existing.createdAt,
updatedAt: now,
});

await db
.update(schema.username)
.set({ fingerprint })
.where(eq(schema.username.fingerprint, existing.fingerprint));

await db
.update(schema.attestation)
.set({ fingerprint })
.where(eq(schema.attestation.fingerprint, existing.fingerprint));

await db
.delete(schema.cryptoProfile)
.where(eq(schema.cryptoProfile.fingerprint, existing.fingerprint));
}

return c.json({ fingerprint, uri: aspeUriFor(c.env.ASPE_DOMAIN, fingerprint) });
}

if (action === "delete") {
const [existing] = await db
Expand Down Expand Up @@ -429,7 +501,7 @@ aspe.post("/api/identity/email/challenge", requireAuth, async (c) => {
`,
});

return c.json({ challenge, email: user.email, expiresAt: expiresAt.toISOString() });
return c.json({ email: user.email, expiresAt: expiresAt.toISOString() });
});

// Step 2: Verify signed challenge and create attestation
Expand Down
Loading
Loading