Skip to content

Commit 2a2b7bd

Browse files
authored
Merge pull request #111 from heymide/kms-reference
Add Custodial Wallet Persistence and KMS References
2 parents 4fe442e + 018bc47 commit 2a2b7bd

10 files changed

Lines changed: 561 additions & 0 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
-- CreateTable
2+
CREATE TABLE "managed_keys" (
3+
"id" TEXT NOT NULL,
4+
"provider" TEXT NOT NULL,
5+
"keyId" TEXT NOT NULL,
6+
"keyVersion" TEXT,
7+
"envelope" TEXT,
8+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
9+
"updatedAt" TIMESTAMP(3) NOT NULL,
10+
11+
CONSTRAINT "managed_keys_pkey" PRIMARY KEY ("id")
12+
);
13+
14+
-- CreateTable
15+
CREATE TABLE "wallets" (
16+
"id" TEXT NOT NULL,
17+
"userId" TEXT NOT NULL,
18+
"network" TEXT NOT NULL,
19+
"custody" TEXT NOT NULL,
20+
"publicKey" TEXT NOT NULL,
21+
"status" TEXT NOT NULL DEFAULT 'PROVISIONING',
22+
"managedKeyId" TEXT,
23+
"statusChangedAt" TIMESTAMP(3),
24+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
25+
"updatedAt" TIMESTAMP(3) NOT NULL,
26+
27+
CONSTRAINT "wallets_pkey" PRIMARY KEY ("id")
28+
);
29+
30+
-- CreateIndex
31+
CREATE UNIQUE INDEX "wallets_userId_key" ON "wallets"("userId");
32+
33+
-- CreateIndex
34+
CREATE UNIQUE INDEX "wallets_publicKey_key" ON "wallets"("publicKey");
35+
36+
-- CreateIndex
37+
CREATE UNIQUE INDEX "wallets_managedKeyId_key" ON "wallets"("managedKeyId");
38+
39+
-- CreateIndex
40+
CREATE INDEX "wallets_userId_status_idx" ON "wallets"("userId", "status");
41+
42+
-- AddForeignKey
43+
ALTER TABLE "wallets" ADD CONSTRAINT "wallets_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
44+
45+
-- AddForeignKey
46+
ALTER TABLE "wallets" ADD CONSTRAINT "wallets_managedKeyId_fkey" FOREIGN KEY ("managedKeyId") REFERENCES "managed_keys"("id") ON DELETE SET NULL ON UPDATE CASCADE;

prisma/schema.prisma

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ model User {
4040
dataExports DataExportRequest[]
4141
deletionRequests AccountDeletionRequest[]
4242
otpChallenges OtpChallenge[]
43+
wallet Wallet?
4344
4445
@@map("users")
4546
}
@@ -418,3 +419,35 @@ model AccountDeletionRequest {
418419
@@index([status, scheduledFor])
419420
@@map("account_deletion_requests")
420421
}
422+
423+
model ManagedKey {
424+
id String @id @default(uuid())
425+
provider String // e.g. "aws_kms", "gcp_kms", "fake"
426+
keyId String // opaque provider-side key identifier
427+
keyVersion String? // key material version for rotation tracking
428+
envelope String? // JSON – encrypted envelope metadata (never plaintext secrets)
429+
createdAt DateTime @default(now())
430+
updatedAt DateTime @updatedAt
431+
432+
wallet Wallet?
433+
434+
@@map("managed_keys")
435+
}
436+
437+
model Wallet {
438+
id String @id @default(uuid())
439+
userId String @unique
440+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
441+
network String // TESTNET, MAINNET
442+
custody String // MANAGED, EXTERNAL
443+
publicKey String @unique
444+
status String @default("PROVISIONING") // PROVISIONING, ACTIVE, FAILED, EXPORT, MIGRATED, DISABLED
445+
managedKeyId String? @unique
446+
managedKey ManagedKey? @relation(fields: [managedKeyId], references: [id], onDelete: SetNull)
447+
statusChangedAt DateTime?
448+
createdAt DateTime @default(now())
449+
updatedAt DateTime @updatedAt
450+
451+
@@index([userId, status])
452+
@@map("wallets")
453+
}

src/services/kms/fake-kms.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { KMS, KmsKeyHandle, CreateKeyResult } from './kms.interface'
2+
3+
function fakeUuid(): string {
4+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
5+
const r = (Math.random() * 16) | 0
6+
const v = c === 'x' ? r : (r & 0x3) | 0x8
7+
return v.toString(16)
8+
})
9+
}
10+
11+
/**
12+
* In-memory fake KMS for development / testing.
13+
*
14+
* NEVER stores real secrets – only opaque UUIDs and version strings.
15+
*/
16+
export class FakeKMS implements KMS {
17+
private keys = new Map<string, { version: number; envelope: string | null; destroyed: boolean }>()
18+
19+
async createKey(label?: string): Promise<CreateKeyResult> {
20+
const keyId = `fake-${fakeUuid()}`
21+
const version = '1'
22+
const envelope = label ? JSON.stringify({ label }) : null
23+
24+
this.keys.set(keyId, { version: 1, envelope, destroyed: false })
25+
26+
return {
27+
handle: { keyId, keyVersion: version, envelope },
28+
provider: 'fake',
29+
}
30+
}
31+
32+
async getKeyHandle(keyId: string): Promise<KmsKeyHandle | null> {
33+
const entry = this.keys.get(keyId)
34+
if (!entry || entry.destroyed) return null
35+
36+
return {
37+
keyId,
38+
keyVersion: String(entry.version),
39+
envelope: entry.envelope,
40+
}
41+
}
42+
43+
async rotateKey(keyId: string): Promise<KmsKeyHandle> {
44+
const entry = this.keys.get(keyId)
45+
if (!entry || entry.destroyed) {
46+
throw new Error(`Key ${keyId} not found`)
47+
}
48+
49+
entry.version += 1
50+
51+
return {
52+
keyId,
53+
keyVersion: String(entry.version),
54+
envelope: entry.envelope,
55+
}
56+
}
57+
58+
async scheduleDestruction(keyId: string, _pendingWindowDays?: number): Promise<void> {
59+
const entry = this.keys.get(keyId)
60+
if (!entry) {
61+
throw new Error(`Key ${keyId} not found`)
62+
}
63+
entry.destroyed = true
64+
}
65+
66+
// ── Test helpers ──────────────────────────────────────
67+
68+
/** Returns number of keys currently stored (including destroyed). */
69+
get size(): number {
70+
return this.keys.size
71+
}
72+
73+
/** Check whether a key has been marked destroyed. */
74+
isDestroyed(keyId: string): boolean {
75+
return this.keys.get(keyId)?.destroyed ?? false
76+
}
77+
}

src/services/kms/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export type { KMS, KmsKeyHandle, CreateKeyResult } from './kms.interface'
2+
export { FakeKMS } from './fake-kms'

src/services/kms/kms.interface.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* KMS (Key Management Service) interface.
3+
*
4+
* Implementations wrap a specific provider (AWS KMS, GCP KMS, etc.)
5+
* and never expose plaintext secrets to the application layer.
6+
*/
7+
8+
export interface KmsKeyHandle {
9+
/** Opaque provider-side key identifier */
10+
keyId: string
11+
/** Key material version (for rotation tracking) */
12+
keyVersion: string | null
13+
/** Opaque encrypted envelope – never plaintext */
14+
envelope: string | null
15+
}
16+
17+
export interface CreateKeyResult {
18+
handle: KmsKeyHandle
19+
provider: string
20+
}
21+
22+
export interface KMS {
23+
/**
24+
* Create a new managed key.
25+
* Returns only opaque references – never plaintext secrets.
26+
*/
27+
createKey(label?: string): Promise<CreateKeyResult>
28+
29+
/**
30+
* Retrieve the opaque handle for an existing key.
31+
*/
32+
getKeyHandle(keyId: string): Promise<KmsKeyHandle | null>
33+
34+
/**
35+
* Rotate key material. Returns updated handle.
36+
*/
37+
rotateKey(keyId: string): Promise<KmsKeyHandle>
38+
39+
/**
40+
* Schedule key for destruction (soft-delete).
41+
*/
42+
scheduleDestruction(keyId: string, pendingWindowDays?: number): Promise<void>
43+
}

src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from './module.types'
33
export * from './reward.types'
44
export * from './credential.types'
55
export * from './api.types'
6+
export * from './wallet.types'

src/types/wallet.types.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// ── Wallet status & transition guards ───
2+
3+
export const WalletStatus = {
4+
PROVISIONING: 'PROVISIONING',
5+
ACTIVE: 'ACTIVE',
6+
FAILED: 'FAILED',
7+
EXPORT: 'EXPORT',
8+
MIGRATED: 'MIGRATED',
9+
DISABLED: 'DISABLED',
10+
} as const
11+
12+
export type WalletStatusValue = (typeof WalletStatus)[keyof typeof WalletStatus]
13+
14+
export const WalletNetwork = {
15+
TESTNET: 'TESTNET',
16+
MAINNET: 'MAINNET',
17+
} as const
18+
19+
export type WalletNetworkValue = (typeof WalletNetwork)[keyof typeof WalletNetwork]
20+
21+
export const WalletCustody = {
22+
MANAGED: 'MANAGED',
23+
EXTERNAL: 'EXTERNAL',
24+
} as const
25+
26+
export type WalletCustodyValue = (typeof WalletCustody)[keyof typeof WalletCustody]
27+
28+
/**
29+
* Allowed status transitions.
30+
* Key = current status, Value = set of statuses it may transition to.
31+
*/
32+
export const WalletTransitions: Readonly<Record<WalletStatusValue, ReadonlySet<WalletStatusValue>>> = {
33+
[WalletStatus.PROVISIONING]: new Set([WalletStatus.ACTIVE, WalletStatus.FAILED]),
34+
[WalletStatus.ACTIVE]: new Set([WalletStatus.EXPORT, WalletStatus.MIGRATED, WalletStatus.DISABLED]),
35+
[WalletStatus.FAILED]: new Set([WalletStatus.PROVISIONING, WalletStatus.DISABLED]),
36+
[WalletStatus.EXPORT]: new Set([WalletStatus.MIGRATED, WalletStatus.DISABLED]),
37+
[WalletStatus.MIGRATED]: new Set([WalletStatus.DISABLED]),
38+
[WalletStatus.DISABLED]: new Set([]),
39+
} as const
40+
41+
/**
42+
* Returns true if transitioning from `from` to `to` is allowed.
43+
*/
44+
export function isValidWalletTransition(from: WalletStatusValue, to: WalletStatusValue): boolean {
45+
return WalletTransitions[from]?.has(to) ?? false
46+
}
47+
48+
// ── DTOs (no plaintext secrets) ───
49+
50+
export interface WalletDto {
51+
id: string
52+
userId: string
53+
network: WalletNetworkValue
54+
custody: WalletCustodyValue
55+
publicKey: string
56+
status: WalletStatusValue
57+
managedKeyId: string | null
58+
statusChangedAt: string | null
59+
createdAt: string
60+
updatedAt: string
61+
}
62+
63+
export interface ManagedKeyDto {
64+
id: string
65+
provider: string
66+
keyId: string
67+
keyVersion: string | null
68+
/** Opaque envelope – never contains plaintext secrets */
69+
envelope: string | null
70+
createdAt: string
71+
updatedAt: string
72+
}
73+
74+
/**
75+
* Redacts sensitive fields from a wallet object before returning to API consumers.
76+
* Ensures managed key references never leak into ordinary responses.
77+
*/
78+
export function redactWallet(wallet: Record<string, unknown>): WalletDto {
79+
return {
80+
id: wallet.id as string,
81+
userId: wallet.userId as string,
82+
network: wallet.network as WalletNetworkValue,
83+
custody: wallet.custody as WalletCustodyValue,
84+
publicKey: wallet.publicKey as string,
85+
status: wallet.status as WalletStatusValue,
86+
managedKeyId: null, // never expose internal KMS reference
87+
statusChangedAt: wallet.statusChangedAt ? String(wallet.statusChangedAt) : null,
88+
createdAt: String(wallet.createdAt),
89+
updatedAt: String(wallet.updatedAt),
90+
}
91+
}

0 commit comments

Comments
 (0)