Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
-- CreateTable
CREATE TABLE "managed_keys" (
"id" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"keyId" TEXT NOT NULL,
"keyVersion" TEXT,
"envelope" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "managed_keys_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "wallets" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"network" TEXT NOT NULL,
"custody" TEXT NOT NULL,
"publicKey" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PROVISIONING',
"managedKeyId" TEXT,
"statusChangedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "wallets_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "wallets_userId_key" ON "wallets"("userId");

-- CreateIndex
CREATE UNIQUE INDEX "wallets_publicKey_key" ON "wallets"("publicKey");

-- CreateIndex
CREATE UNIQUE INDEX "wallets_managedKeyId_key" ON "wallets"("managedKeyId");

-- CreateIndex
CREATE INDEX "wallets_userId_status_idx" ON "wallets"("userId", "status");

-- AddForeignKey
ALTER TABLE "wallets" ADD CONSTRAINT "wallets_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "wallets" ADD CONSTRAINT "wallets_managedKeyId_fkey" FOREIGN KEY ("managedKeyId") REFERENCES "managed_keys"("id") ON DELETE SET NULL ON UPDATE CASCADE;
33 changes: 33 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ model User {
dataExports DataExportRequest[]
deletionRequests AccountDeletionRequest[]
otpChallenges OtpChallenge[]
wallet Wallet?

@@map("users")
}
Expand Down Expand Up @@ -418,3 +419,35 @@ model AccountDeletionRequest {
@@index([status, scheduledFor])
@@map("account_deletion_requests")
}

model ManagedKey {
id String @id @default(uuid())
provider String // e.g. "aws_kms", "gcp_kms", "fake"
keyId String // opaque provider-side key identifier
keyVersion String? // key material version for rotation tracking
envelope String? // JSON – encrypted envelope metadata (never plaintext secrets)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

wallet Wallet?

@@map("managed_keys")
}

model Wallet {
id String @id @default(uuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
network String // TESTNET, MAINNET
custody String // MANAGED, EXTERNAL
publicKey String @unique
status String @default("PROVISIONING") // PROVISIONING, ACTIVE, FAILED, EXPORT, MIGRATED, DISABLED
managedKeyId String? @unique
managedKey ManagedKey? @relation(fields: [managedKeyId], references: [id], onDelete: SetNull)
statusChangedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([userId, status])
@@map("wallets")
}
77 changes: 77 additions & 0 deletions src/services/kms/fake-kms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { KMS, KmsKeyHandle, CreateKeyResult } from './kms.interface'

function fakeUuid(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}

/**
* In-memory fake KMS for development / testing.
*
* NEVER stores real secrets – only opaque UUIDs and version strings.
*/
export class FakeKMS implements KMS {
private keys = new Map<string, { version: number; envelope: string | null; destroyed: boolean }>()

async createKey(label?: string): Promise<CreateKeyResult> {
const keyId = `fake-${fakeUuid()}`
const version = '1'
const envelope = label ? JSON.stringify({ label }) : null

this.keys.set(keyId, { version: 1, envelope, destroyed: false })

return {
handle: { keyId, keyVersion: version, envelope },
provider: 'fake',
}
}

async getKeyHandle(keyId: string): Promise<KmsKeyHandle | null> {
const entry = this.keys.get(keyId)
if (!entry || entry.destroyed) return null

return {
keyId,
keyVersion: String(entry.version),
envelope: entry.envelope,
}
}

async rotateKey(keyId: string): Promise<KmsKeyHandle> {
const entry = this.keys.get(keyId)
if (!entry || entry.destroyed) {
throw new Error(`Key ${keyId} not found`)
}

entry.version += 1

return {
keyId,
keyVersion: String(entry.version),
envelope: entry.envelope,
}
}

async scheduleDestruction(keyId: string, _pendingWindowDays?: number): Promise<void> {
const entry = this.keys.get(keyId)
if (!entry) {
throw new Error(`Key ${keyId} not found`)
}
entry.destroyed = true
}

// ── Test helpers ──────────────────────────────────────

/** Returns number of keys currently stored (including destroyed). */
get size(): number {
return this.keys.size
}

/** Check whether a key has been marked destroyed. */
isDestroyed(keyId: string): boolean {
return this.keys.get(keyId)?.destroyed ?? false
}
}
2 changes: 2 additions & 0 deletions src/services/kms/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type { KMS, KmsKeyHandle, CreateKeyResult } from './kms.interface'
export { FakeKMS } from './fake-kms'
43 changes: 43 additions & 0 deletions src/services/kms/kms.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* KMS (Key Management Service) interface.
*
* Implementations wrap a specific provider (AWS KMS, GCP KMS, etc.)
* and never expose plaintext secrets to the application layer.
*/

export interface KmsKeyHandle {
/** Opaque provider-side key identifier */
keyId: string
/** Key material version (for rotation tracking) */
keyVersion: string | null
/** Opaque encrypted envelope – never plaintext */
envelope: string | null
}

export interface CreateKeyResult {
handle: KmsKeyHandle
provider: string
}

export interface KMS {
/**
* Create a new managed key.
* Returns only opaque references – never plaintext secrets.
*/
createKey(label?: string): Promise<CreateKeyResult>

/**
* Retrieve the opaque handle for an existing key.
*/
getKeyHandle(keyId: string): Promise<KmsKeyHandle | null>

/**
* Rotate key material. Returns updated handle.
*/
rotateKey(keyId: string): Promise<KmsKeyHandle>

/**
* Schedule key for destruction (soft-delete).
*/
scheduleDestruction(keyId: string, pendingWindowDays?: number): Promise<void>
}
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './module.types'
export * from './reward.types'
export * from './credential.types'
export * from './api.types'
export * from './wallet.types'
91 changes: 91 additions & 0 deletions src/types/wallet.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// ── Wallet status & transition guards ───

export const WalletStatus = {
PROVISIONING: 'PROVISIONING',
ACTIVE: 'ACTIVE',
FAILED: 'FAILED',
EXPORT: 'EXPORT',
MIGRATED: 'MIGRATED',
DISABLED: 'DISABLED',
} as const

export type WalletStatusValue = (typeof WalletStatus)[keyof typeof WalletStatus]

export const WalletNetwork = {
TESTNET: 'TESTNET',
MAINNET: 'MAINNET',
} as const

export type WalletNetworkValue = (typeof WalletNetwork)[keyof typeof WalletNetwork]

export const WalletCustody = {
MANAGED: 'MANAGED',
EXTERNAL: 'EXTERNAL',
} as const

export type WalletCustodyValue = (typeof WalletCustody)[keyof typeof WalletCustody]

/**
* Allowed status transitions.
* Key = current status, Value = set of statuses it may transition to.
*/
export const WalletTransitions: Readonly<Record<WalletStatusValue, ReadonlySet<WalletStatusValue>>> = {
[WalletStatus.PROVISIONING]: new Set([WalletStatus.ACTIVE, WalletStatus.FAILED]),
[WalletStatus.ACTIVE]: new Set([WalletStatus.EXPORT, WalletStatus.MIGRATED, WalletStatus.DISABLED]),
[WalletStatus.FAILED]: new Set([WalletStatus.PROVISIONING, WalletStatus.DISABLED]),
[WalletStatus.EXPORT]: new Set([WalletStatus.MIGRATED, WalletStatus.DISABLED]),
[WalletStatus.MIGRATED]: new Set([WalletStatus.DISABLED]),
[WalletStatus.DISABLED]: new Set([]),
} as const

/**
* Returns true if transitioning from `from` to `to` is allowed.
*/
export function isValidWalletTransition(from: WalletStatusValue, to: WalletStatusValue): boolean {
return WalletTransitions[from]?.has(to) ?? false
}

// ── DTOs (no plaintext secrets) ───

export interface WalletDto {
id: string
userId: string
network: WalletNetworkValue
custody: WalletCustodyValue
publicKey: string
status: WalletStatusValue
managedKeyId: string | null
statusChangedAt: string | null
createdAt: string
updatedAt: string
}

export interface ManagedKeyDto {
id: string
provider: string
keyId: string
keyVersion: string | null
/** Opaque envelope – never contains plaintext secrets */
envelope: string | null
createdAt: string
updatedAt: string
}

/**
* Redacts sensitive fields from a wallet object before returning to API consumers.
* Ensures managed key references never leak into ordinary responses.
*/
export function redactWallet(wallet: Record<string, unknown>): WalletDto {
return {
id: wallet.id as string,
userId: wallet.userId as string,
network: wallet.network as WalletNetworkValue,
custody: wallet.custody as WalletCustodyValue,
publicKey: wallet.publicKey as string,
status: wallet.status as WalletStatusValue,
managedKeyId: null, // never expose internal KMS reference
statusChangedAt: wallet.statusChangedAt ? String(wallet.statusChangedAt) : null,
createdAt: String(wallet.createdAt),
updatedAt: String(wallet.updatedAt),
}
}
Loading