@jwtwallet/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mehmet Emin Kartal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # jwtwallet
2
+
3
+ Server-side JWKS generation and management for the [JWTWallet Protocol](https://github.com/jwtwallet/protocol).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install jwtwallet
9
+ # or
10
+ yarn add jwtwallet
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ### Create a Wallet
16
+
17
+ ```typescript
18
+ import { JWTWallet } from 'jwtwallet';
19
+
20
+ // Create with default platform (jwtwallet.com)
21
+ const wallet = await JWTWallet.create();
22
+
23
+ // Or with custom platform
24
+ const wallet = await JWTWallet.create({
25
+ platform: 'keys.mycompany.com',
26
+ });
27
+
28
+ console.log(wallet.accountId); // "abc123..." → use as subdomain
29
+ console.log(wallet.issuer); // "https://abc123.jwtwallet.com"
30
+ ```
31
+
32
+ ### Add Signing Keys
33
+
34
+ ```typescript
35
+ import * as jose from 'jose';
36
+
37
+ // Generate key pair (you keep the private key!)
38
+ const { publicKey, privateKey } = await jose.generateKeyPair('ES256');
39
+ const publicJWK = await jose.exportJWK(publicKey);
40
+
41
+ // Add only the public key to wallet
42
+ wallet.addSigningKey({
43
+ kid: 'my-key-1',
44
+ alg: 'ES256',
45
+ publicKey: publicJWK,
46
+ });
47
+ ```
48
+
49
+ ### Export Signed JWKS
50
+
51
+ ```typescript
52
+ const jwks = await wallet.signAndExportJwtWalletJWKS();
53
+ // {
54
+ // keys: [{ kty: 'EC', ... }],
55
+ // jwtwallet: {
56
+ // version: 1,
57
+ // accountPublicKey: { ... },
58
+ // signature: "...",
59
+ // revoked: []
60
+ // }
61
+ // }
62
+
63
+ // Host this at: https://{accountId}.{platform}/.well-known/jwks.json
64
+ ```
65
+
66
+ ### Revoke Keys
67
+
68
+ ```typescript
69
+ wallet.revokeKey('my-key-1');
70
+
71
+ const jwks = await wallet.signAndExportJwtWalletJWKS();
72
+ // jwks.jwtwallet.revoked = ['my-key-1']
73
+ ```
74
+
75
+ ### Backup & Restore
76
+
77
+ ```typescript
78
+ // Export (includes account private key)
79
+ const backup = await wallet.export();
80
+ // Store backup securely!
81
+
82
+ // Import
83
+ const restored = await JWTWallet.import(backup);
84
+ ```
85
+
86
+ ### Validate JWKS
87
+
88
+ ```typescript
89
+ import { validateJWKS } from 'jwtwallet';
90
+
91
+ const result = await validateJWKS(jwks, 'https://abc123.jwtwallet.com');
92
+ if (!result.valid) {
93
+ console.error(result.error);
94
+ }
95
+
96
+ // Or self-validate
97
+ const result = await wallet.validate();
98
+ ```
99
+
100
+ ## How It Works
101
+
102
+ 1. **Account Key**: Each wallet has an account key pair (ES256). The public key hash becomes your account ID (subdomain).
103
+
104
+ 2. **Signing Keys**: You generate signing keys externally and register only the public keys with your wallet.
105
+
106
+ 3. **JWKS Signing**: When you export, the wallet signs `canonical(keys) || canonical(accountPublicKey) || issuer` with the account private key.
107
+
108
+ 4. **Verification**: Clients use [jwtwallet-jose](https://github.com/jwtwallet/jwtwallet-jose) to verify the JWKS trust chain before using keys.
109
+
110
+ ## Related
111
+
112
+ - [jwtwallet-jose](https://github.com/jwtwallet/jwtwallet-jose) - Client-side JWKS verification
113
+ - [JWTWallet Protocol](https://github.com/jwtwallet/protocol) - Protocol specification
114
+
115
+ ## License
116
+
117
+ MIT
@@ -0,0 +1,4 @@
1
+ export { JWTWallet } from './wallet.js';
2
+ export { validateJWKS } from './validate.js';
3
+ export { computeAccountId, buildSignedPayload } from './utils.js';
4
+ export type { JWTWalletConfig, JWTWalletJWKS, JWTWalletExtension, AddSigningKeyOptions, SigningKeyInfo, WalletExport, ValidationResult, } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { JWTWallet } from './wallet.js';
2
+ export { validateJWKS } from './validate.js';
3
+ export { computeAccountId, buildSignedPayload } from './utils.js';
@@ -0,0 +1,44 @@
1
+ import * as jose from 'jose';
2
+ export interface JWTWalletConfig {
3
+ /** Platform domain for issuer URL. Default: 'jwtwallet.com' */
4
+ platform?: string;
5
+ /** Default algorithm for account key. Default: 'ES256' */
6
+ algorithm?: 'ES256' | 'ES384' | 'ES512';
7
+ }
8
+ export interface AddSigningKeyOptions {
9
+ /** Key ID. Auto-generated if not provided */
10
+ kid?: string;
11
+ /** Algorithm. Default: 'ES256' */
12
+ alg?: string;
13
+ /** Public key in JWK format */
14
+ publicKey: jose.JWK;
15
+ }
16
+ export interface SigningKeyInfo {
17
+ kid: string;
18
+ alg: string;
19
+ publicKey: jose.JWK;
20
+ }
21
+ export interface JWTWalletExtension {
22
+ version: number;
23
+ accountPublicKey: jose.JWK;
24
+ signature: string;
25
+ revoked: string[];
26
+ }
27
+ export interface JWTWalletJWKS extends jose.JSONWebKeySet {
28
+ jwtwallet: JWTWalletExtension;
29
+ }
30
+ export interface WalletExport {
31
+ version: number;
32
+ accountId: string;
33
+ platform: string;
34
+ accountKeyPair: {
35
+ publicKey: jose.JWK;
36
+ privateKey: jose.JWK;
37
+ };
38
+ signingKeys: SigningKeyInfo[];
39
+ revoked: string[];
40
+ }
41
+ export interface ValidationResult {
42
+ valid: boolean;
43
+ error?: string;
44
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import * as jose from 'jose';
2
+ /**
3
+ * Compute account ID from public key
4
+ * accountId = base64url(sha256(canonical(accountPublicKey))).slice(0, 20).toLowerCase()
5
+ */
6
+ export declare function computeAccountId(accountPublicKey: jose.JWK): Promise<string>;
7
+ /**
8
+ * Build the payload that should be signed
9
+ * payload = canonical(keys) || canonical(accountPublicKey) || issuer
10
+ */
11
+ export declare function buildSignedPayload(keys: jose.JWK[], accountPublicKey: jose.JWK, issuer: string): string;
12
+ /**
13
+ * Generate a unique key ID
14
+ */
15
+ export declare function generateKid(): string;
package/dist/utils.js ADDED
@@ -0,0 +1,27 @@
1
+ import * as jose from 'jose';
2
+ import { createRequire } from 'node:module';
3
+ const require = createRequire(import.meta.url);
4
+ const canonicalize = require('canonicalize');
5
+ /**
6
+ * Compute account ID from public key
7
+ * accountId = base64url(sha256(canonical(accountPublicKey))).slice(0, 20).toLowerCase()
8
+ */
9
+ export async function computeAccountId(accountPublicKey) {
10
+ const canonical = canonicalize(accountPublicKey);
11
+ const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical));
12
+ return jose.base64url.encode(new Uint8Array(hash)).slice(0, 20).toLowerCase();
13
+ }
14
+ /**
15
+ * Build the payload that should be signed
16
+ * payload = canonical(keys) || canonical(accountPublicKey) || issuer
17
+ */
18
+ export function buildSignedPayload(keys, accountPublicKey, issuer) {
19
+ return canonicalize(keys) + canonicalize(accountPublicKey) + issuer;
20
+ }
21
+ /**
22
+ * Generate a unique key ID
23
+ */
24
+ export function generateKid() {
25
+ const bytes = crypto.getRandomValues(new Uint8Array(12));
26
+ return 'k_' + jose.base64url.encode(bytes);
27
+ }
@@ -0,0 +1,11 @@
1
+ import type { JWTWalletJWKS, ValidationResult } from './types.js';
2
+ /**
3
+ * Validate a JWTWallet JWKS
4
+ *
5
+ * Checks:
6
+ * 1. Account ID matches hash of accountPublicKey
7
+ * 2. Signature is valid over (keys || accountPublicKey || issuer)
8
+ * 3. All keys have kid and alg
9
+ * 4. Revoked keys are not in the keys array (warning only)
10
+ */
11
+ export declare function validateJWKS(jwks: JWTWalletJWKS, issuer: string): Promise<ValidationResult>;
@@ -0,0 +1,73 @@
1
+ import * as jose from 'jose';
2
+ import { computeAccountId, buildSignedPayload } from './utils.js';
3
+ /**
4
+ * Extract account ID from issuer URL (subdomain)
5
+ */
6
+ function extractAccountIdFromIssuer(issuer) {
7
+ const url = new URL(issuer);
8
+ return url.hostname.split('.')[0];
9
+ }
10
+ /**
11
+ * Validate a JWTWallet JWKS
12
+ *
13
+ * Checks:
14
+ * 1. Account ID matches hash of accountPublicKey
15
+ * 2. Signature is valid over (keys || accountPublicKey || issuer)
16
+ * 3. All keys have kid and alg
17
+ * 4. Revoked keys are not in the keys array (warning only)
18
+ */
19
+ export async function validateJWKS(jwks, issuer) {
20
+ try {
21
+ const { jwtwallet } = jwks;
22
+ if (!jwtwallet) {
23
+ return { valid: false, error: 'Missing jwtwallet extension' };
24
+ }
25
+ if (jwtwallet.version !== 1) {
26
+ return { valid: false, error: `Unsupported version: ${jwtwallet.version}` };
27
+ }
28
+ const { accountPublicKey, signature, revoked } = jwtwallet;
29
+ // 1. Verify account ID matches public key hash
30
+ const expectedAccountId = await computeAccountId(accountPublicKey);
31
+ const urlAccountId = extractAccountIdFromIssuer(issuer);
32
+ if (expectedAccountId !== urlAccountId) {
33
+ return {
34
+ valid: false,
35
+ error: `Account ID mismatch: expected ${expectedAccountId}, got ${urlAccountId}`,
36
+ };
37
+ }
38
+ // 2. Verify signature
39
+ const payload = buildSignedPayload(jwks.keys, accountPublicKey, issuer);
40
+ const payloadBytes = new TextEncoder().encode(payload);
41
+ const signatureBytes = jose.base64url.decode(signature);
42
+ const publicKey = await jose.importJWK(accountPublicKey, accountPublicKey.alg || 'ES256');
43
+ const isValid = await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, publicKey, new Uint8Array(signatureBytes), new Uint8Array(payloadBytes));
44
+ if (!isValid) {
45
+ return { valid: false, error: 'Invalid signature' };
46
+ }
47
+ // 3. Check all keys have kid and alg
48
+ for (const key of jwks.keys) {
49
+ if (!key.kid) {
50
+ return { valid: false, error: 'Key missing kid' };
51
+ }
52
+ if (!key.alg) {
53
+ return { valid: false, error: `Key ${key.kid} missing alg` };
54
+ }
55
+ }
56
+ // 4. Check revoked keys are not in keys array (optional warning)
57
+ const keyKids = new Set(jwks.keys.map((k) => k.kid));
58
+ for (const revokedKid of revoked || []) {
59
+ if (keyKids.has(revokedKid)) {
60
+ // This is allowed but might indicate an issue
61
+ // Platform should handle this - revoked keys should still be verifiable
62
+ // for tokens issued before revocation
63
+ }
64
+ }
65
+ return { valid: true };
66
+ }
67
+ catch (error) {
68
+ return {
69
+ valid: false,
70
+ error: error instanceof Error ? error.message : 'Unknown error',
71
+ };
72
+ }
73
+ }
@@ -0,0 +1,54 @@
1
+ import * as jose from 'jose';
2
+ import type { JWTWalletConfig, JWTWalletJWKS, AddSigningKeyOptions, SigningKeyInfo, WalletExport, ValidationResult } from './types.js';
3
+ export declare class JWTWallet {
4
+ #private;
5
+ private constructor();
6
+ /**
7
+ * Create a new JWTWallet with a fresh account key pair
8
+ */
9
+ static create(config?: JWTWalletConfig): Promise<JWTWallet>;
10
+ /**
11
+ * Import a wallet from exported data
12
+ */
13
+ static import(data: WalletExport): Promise<JWTWallet>;
14
+ /** Account ID (subdomain) */
15
+ get accountId(): string;
16
+ /** Full issuer URL */
17
+ get issuer(): string;
18
+ /** Platform domain */
19
+ get platform(): string;
20
+ /** Account public key */
21
+ get accountPublicKey(): jose.JWK;
22
+ /** List of signing keys */
23
+ get signingKeys(): SigningKeyInfo[];
24
+ /** List of revoked key IDs */
25
+ get revokedKeys(): string[];
26
+ /**
27
+ * Add a signing key (public key only)
28
+ */
29
+ addSigningKey(options: AddSigningKeyOptions): SigningKeyInfo;
30
+ /**
31
+ * Remove a signing key
32
+ */
33
+ removeSigningKey(kid: string): boolean;
34
+ /**
35
+ * Revoke a signing key (adds to revoked list, does not remove from keys)
36
+ */
37
+ revokeKey(kid: string): void;
38
+ /**
39
+ * Unrevoke a signing key
40
+ */
41
+ unrevokeKey(kid: string): boolean;
42
+ /**
43
+ * Sign and export the JWKS with JWTWallet extension
44
+ */
45
+ signAndExportJwtWalletJWKS(): Promise<JWTWalletJWKS>;
46
+ /**
47
+ * Export wallet for backup (includes account private key)
48
+ */
49
+ export(): Promise<WalletExport>;
50
+ /**
51
+ * Validate the current wallet state
52
+ */
53
+ validate(): Promise<ValidationResult>;
54
+ }
package/dist/wallet.js ADDED
@@ -0,0 +1,163 @@
1
+ import * as jose from 'jose';
2
+ import { computeAccountId, buildSignedPayload, generateKid } from './utils.js';
3
+ import { validateJWKS } from './validate.js';
4
+ const DEFAULT_PLATFORM = 'jwtwallet.com';
5
+ const DEFAULT_ALGORITHM = 'ES256';
6
+ const WALLET_VERSION = 1;
7
+ export class JWTWallet {
8
+ #platform;
9
+ #algorithm;
10
+ #accountId;
11
+ #accountPublicKey;
12
+ #accountPrivateKey;
13
+ #signingKeys = new Map();
14
+ #revoked = new Set();
15
+ constructor(platform, algorithm, accountId, accountPublicKey, accountPrivateKey) {
16
+ this.#platform = platform;
17
+ this.#algorithm = algorithm;
18
+ this.#accountId = accountId;
19
+ this.#accountPublicKey = accountPublicKey;
20
+ this.#accountPrivateKey = accountPrivateKey;
21
+ }
22
+ /**
23
+ * Create a new JWTWallet with a fresh account key pair
24
+ */
25
+ static async create(config = {}) {
26
+ const platform = config.platform ?? DEFAULT_PLATFORM;
27
+ const algorithm = config.algorithm ?? DEFAULT_ALGORITHM;
28
+ // Generate account key pair
29
+ const { publicKey, privateKey } = await jose.generateKeyPair(algorithm, {
30
+ extractable: true,
31
+ });
32
+ const accountPublicKey = await jose.exportJWK(publicKey);
33
+ // Add alg to public key for clarity
34
+ accountPublicKey.alg = algorithm;
35
+ const accountId = await computeAccountId(accountPublicKey);
36
+ return new JWTWallet(platform, algorithm, accountId, accountPublicKey, privateKey);
37
+ }
38
+ /**
39
+ * Import a wallet from exported data
40
+ */
41
+ static async import(data) {
42
+ const algorithm = data.accountKeyPair.publicKey.alg ?? DEFAULT_ALGORITHM;
43
+ // Import the private key
44
+ const privateKey = await jose.importJWK(data.accountKeyPair.privateKey, algorithm);
45
+ const wallet = new JWTWallet(data.platform, algorithm, data.accountId, data.accountKeyPair.publicKey, privateKey);
46
+ // Restore signing keys
47
+ for (const key of data.signingKeys) {
48
+ wallet.#signingKeys.set(key.kid, key);
49
+ }
50
+ // Restore revoked keys
51
+ for (const kid of data.revoked) {
52
+ wallet.#revoked.add(kid);
53
+ }
54
+ return wallet;
55
+ }
56
+ /** Account ID (subdomain) */
57
+ get accountId() {
58
+ return this.#accountId;
59
+ }
60
+ /** Full issuer URL */
61
+ get issuer() {
62
+ return `https://${this.#accountId}.${this.#platform}`;
63
+ }
64
+ /** Platform domain */
65
+ get platform() {
66
+ return this.#platform;
67
+ }
68
+ /** Account public key */
69
+ get accountPublicKey() {
70
+ return { ...this.#accountPublicKey };
71
+ }
72
+ /** List of signing keys */
73
+ get signingKeys() {
74
+ return Array.from(this.#signingKeys.values());
75
+ }
76
+ /** List of revoked key IDs */
77
+ get revokedKeys() {
78
+ return Array.from(this.#revoked);
79
+ }
80
+ /**
81
+ * Add a signing key (public key only)
82
+ */
83
+ addSigningKey(options) {
84
+ const kid = options.kid ?? generateKid();
85
+ const alg = options.alg ?? DEFAULT_ALGORITHM;
86
+ // Ensure the public key has kid and alg
87
+ const publicKey = {
88
+ ...options.publicKey,
89
+ kid,
90
+ alg,
91
+ use: 'sig',
92
+ };
93
+ const keyInfo = { kid, alg, publicKey };
94
+ this.#signingKeys.set(kid, keyInfo);
95
+ return keyInfo;
96
+ }
97
+ /**
98
+ * Remove a signing key
99
+ */
100
+ removeSigningKey(kid) {
101
+ return this.#signingKeys.delete(kid);
102
+ }
103
+ /**
104
+ * Revoke a signing key (adds to revoked list, does not remove from keys)
105
+ */
106
+ revokeKey(kid) {
107
+ this.#revoked.add(kid);
108
+ }
109
+ /**
110
+ * Unrevoke a signing key
111
+ */
112
+ unrevokeKey(kid) {
113
+ return this.#revoked.delete(kid);
114
+ }
115
+ /**
116
+ * Sign and export the JWKS with JWTWallet extension
117
+ */
118
+ async signAndExportJwtWalletJWKS() {
119
+ // Collect all public keys (excluding revoked from keys array is optional,
120
+ // but revoked list will be in the extension)
121
+ const keys = Array.from(this.#signingKeys.values()).map((k) => k.publicKey);
122
+ // Build the payload to sign
123
+ const payload = buildSignedPayload(keys, this.#accountPublicKey, this.issuer);
124
+ const payloadBytes = new TextEncoder().encode(payload);
125
+ // Sign with account private key
126
+ const signatureBytes = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, this.#accountPrivateKey, payloadBytes);
127
+ const signature = jose.base64url.encode(new Uint8Array(signatureBytes));
128
+ return {
129
+ keys,
130
+ jwtwallet: {
131
+ version: WALLET_VERSION,
132
+ accountPublicKey: this.#accountPublicKey,
133
+ signature,
134
+ revoked: Array.from(this.#revoked),
135
+ },
136
+ };
137
+ }
138
+ /**
139
+ * Export wallet for backup (includes account private key)
140
+ */
141
+ async export() {
142
+ // Export private key
143
+ const privateKeyJWK = await crypto.subtle.exportKey('jwk', this.#accountPrivateKey);
144
+ return {
145
+ version: WALLET_VERSION,
146
+ accountId: this.#accountId,
147
+ platform: this.#platform,
148
+ accountKeyPair: {
149
+ publicKey: this.#accountPublicKey,
150
+ privateKey: privateKeyJWK,
151
+ },
152
+ signingKeys: Array.from(this.#signingKeys.values()),
153
+ revoked: Array.from(this.#revoked),
154
+ };
155
+ }
156
+ /**
157
+ * Validate the current wallet state
158
+ */
159
+ async validate() {
160
+ const jwks = await this.signAndExportJwtWalletJWKS();
161
+ return validateJWKS(jwks, this.issuer);
162
+ }
163
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@jwtwallet/core",
3
+ "version": "0.1.0",
4
+ "description": "JWTWallet Protocol - Server-side JWKS generation and management",
5
+ "author": {
6
+ "name": "Mehmet Emin Kartal",
7
+ "email": "mehmet@appac.ltd"
8
+ },
9
+ "license": "MIT",
10
+ "type": "module",
11
+ "main": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "import": "./dist/index.js",
16
+ "types": "./dist/index.d.ts"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "src"
22
+ ],
23
+ "scripts": {
24
+ "build": "tsc",
25
+ "test": "vitest run",
26
+ "test:watch": "vitest",
27
+ "release": "semantic-release"
28
+ },
29
+ "keywords": [
30
+ "jwt",
31
+ "jwks",
32
+ "jwtwallet",
33
+ "jose",
34
+ "authentication",
35
+ "trustless"
36
+ ],
37
+ "dependencies": {
38
+ "canonicalize": "^2.1.0",
39
+ "jose": "^6.1.0"
40
+ },
41
+ "devDependencies": {
42
+ "@semantic-release/commit-analyzer": "^13.0.1",
43
+ "@semantic-release/github": "^12.0.6",
44
+ "@semantic-release/npm": "^13.1.5",
45
+ "@semantic-release/release-notes-generator": "^14.1.0",
46
+ "@types/node": "^22.0.0",
47
+ "semantic-release": "^25.0.3",
48
+ "typescript": "^5.3.0",
49
+ "vitest": "^4.1.0"
50
+ },
51
+ "engines": {
52
+ "node": ">=18"
53
+ },
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "https://github.com/jwtwallet/jwtwallet"
57
+ }
58
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export { JWTWallet } from './wallet.js';
2
+ export { validateJWKS } from './validate.js';
3
+ export { computeAccountId, buildSignedPayload } from './utils.js';
4
+ export type {
5
+ JWTWalletConfig,
6
+ JWTWalletJWKS,
7
+ JWTWalletExtension,
8
+ AddSigningKeyOptions,
9
+ SigningKeyInfo,
10
+ WalletExport,
11
+ ValidationResult,
12
+ } from './types.js';
package/src/types.ts ADDED
@@ -0,0 +1,61 @@
1
+ import * as jose from 'jose';
2
+
3
+ // ============ Config ============
4
+
5
+ export interface JWTWalletConfig {
6
+ /** Platform domain for issuer URL. Default: 'jwtwallet.com' */
7
+ platform?: string;
8
+ /** Default algorithm for account key. Default: 'ES256' */
9
+ algorithm?: 'ES256' | 'ES384' | 'ES512';
10
+ }
11
+
12
+ // ============ Signing Key ============
13
+
14
+ export interface AddSigningKeyOptions {
15
+ /** Key ID. Auto-generated if not provided */
16
+ kid?: string;
17
+ /** Algorithm. Default: 'ES256' */
18
+ alg?: string;
19
+ /** Public key in JWK format */
20
+ publicKey: jose.JWK;
21
+ }
22
+
23
+ export interface SigningKeyInfo {
24
+ kid: string;
25
+ alg: string;
26
+ publicKey: jose.JWK;
27
+ }
28
+
29
+ // ============ JWKS Types ============
30
+
31
+ export interface JWTWalletExtension {
32
+ version: number;
33
+ accountPublicKey: jose.JWK;
34
+ signature: string;
35
+ revoked: string[];
36
+ }
37
+
38
+ export interface JWTWalletJWKS extends jose.JSONWebKeySet {
39
+ jwtwallet: JWTWalletExtension;
40
+ }
41
+
42
+ // ============ Export/Import ============
43
+
44
+ export interface WalletExport {
45
+ version: number;
46
+ accountId: string;
47
+ platform: string;
48
+ accountKeyPair: {
49
+ publicKey: jose.JWK;
50
+ privateKey: jose.JWK;
51
+ };
52
+ signingKeys: SigningKeyInfo[];
53
+ revoked: string[];
54
+ }
55
+
56
+ // ============ Validation ============
57
+
58
+ export interface ValidationResult {
59
+ valid: boolean;
60
+ error?: string;
61
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,38 @@
1
+ import * as jose from 'jose';
2
+ import { createRequire } from 'node:module';
3
+
4
+ const require = createRequire(import.meta.url);
5
+ const canonicalize = require('canonicalize') as (input: unknown) => string | undefined;
6
+
7
+ /**
8
+ * Compute account ID from public key
9
+ * accountId = base64url(sha256(canonical(accountPublicKey))).slice(0, 20).toLowerCase()
10
+ */
11
+ export async function computeAccountId(accountPublicKey: jose.JWK): Promise<string> {
12
+ const canonical = canonicalize(accountPublicKey)!;
13
+ const hash = await crypto.subtle.digest(
14
+ 'SHA-256',
15
+ new TextEncoder().encode(canonical)
16
+ );
17
+ return jose.base64url.encode(new Uint8Array(hash)).slice(0, 20).toLowerCase();
18
+ }
19
+
20
+ /**
21
+ * Build the payload that should be signed
22
+ * payload = canonical(keys) || canonical(accountPublicKey) || issuer
23
+ */
24
+ export function buildSignedPayload(
25
+ keys: jose.JWK[],
26
+ accountPublicKey: jose.JWK,
27
+ issuer: string
28
+ ): string {
29
+ return canonicalize(keys)! + canonicalize(accountPublicKey)! + issuer;
30
+ }
31
+
32
+ /**
33
+ * Generate a unique key ID
34
+ */
35
+ export function generateKid(): string {
36
+ const bytes = crypto.getRandomValues(new Uint8Array(12));
37
+ return 'k_' + jose.base64url.encode(bytes);
38
+ }
@@ -0,0 +1,98 @@
1
+ import * as jose from 'jose';
2
+ import type { JWTWalletJWKS, ValidationResult } from './types.js';
3
+ import { computeAccountId, buildSignedPayload } from './utils.js';
4
+
5
+ /**
6
+ * Extract account ID from issuer URL (subdomain)
7
+ */
8
+ function extractAccountIdFromIssuer(issuer: string): string {
9
+ const url = new URL(issuer);
10
+ return url.hostname.split('.')[0];
11
+ }
12
+
13
+ /**
14
+ * Validate a JWTWallet JWKS
15
+ *
16
+ * Checks:
17
+ * 1. Account ID matches hash of accountPublicKey
18
+ * 2. Signature is valid over (keys || accountPublicKey || issuer)
19
+ * 3. All keys have kid and alg
20
+ * 4. Revoked keys are not in the keys array (warning only)
21
+ */
22
+ export async function validateJWKS(
23
+ jwks: JWTWalletJWKS,
24
+ issuer: string
25
+ ): Promise<ValidationResult> {
26
+ try {
27
+ const { jwtwallet } = jwks;
28
+
29
+ if (!jwtwallet) {
30
+ return { valid: false, error: 'Missing jwtwallet extension' };
31
+ }
32
+
33
+ if (jwtwallet.version !== 1) {
34
+ return { valid: false, error: `Unsupported version: ${jwtwallet.version}` };
35
+ }
36
+
37
+ const { accountPublicKey, signature, revoked } = jwtwallet;
38
+
39
+ // 1. Verify account ID matches public key hash
40
+ const expectedAccountId = await computeAccountId(accountPublicKey);
41
+ const urlAccountId = extractAccountIdFromIssuer(issuer);
42
+
43
+ if (expectedAccountId !== urlAccountId) {
44
+ return {
45
+ valid: false,
46
+ error: `Account ID mismatch: expected ${expectedAccountId}, got ${urlAccountId}`,
47
+ };
48
+ }
49
+
50
+ // 2. Verify signature
51
+ const payload = buildSignedPayload(jwks.keys, accountPublicKey, issuer);
52
+ const payloadBytes = new TextEncoder().encode(payload);
53
+ const signatureBytes = jose.base64url.decode(signature);
54
+
55
+ const publicKey = await jose.importJWK(
56
+ accountPublicKey,
57
+ (accountPublicKey.alg as string) || 'ES256'
58
+ );
59
+
60
+ const isValid = await crypto.subtle.verify(
61
+ { name: 'ECDSA', hash: 'SHA-256' },
62
+ publicKey as CryptoKey,
63
+ new Uint8Array(signatureBytes),
64
+ new Uint8Array(payloadBytes)
65
+ );
66
+
67
+ if (!isValid) {
68
+ return { valid: false, error: 'Invalid signature' };
69
+ }
70
+
71
+ // 3. Check all keys have kid and alg
72
+ for (const key of jwks.keys) {
73
+ if (!key.kid) {
74
+ return { valid: false, error: 'Key missing kid' };
75
+ }
76
+ if (!key.alg) {
77
+ return { valid: false, error: `Key ${key.kid} missing alg` };
78
+ }
79
+ }
80
+
81
+ // 4. Check revoked keys are not in keys array (optional warning)
82
+ const keyKids = new Set(jwks.keys.map((k) => k.kid));
83
+ for (const revokedKid of revoked || []) {
84
+ if (keyKids.has(revokedKid)) {
85
+ // This is allowed but might indicate an issue
86
+ // Platform should handle this - revoked keys should still be verifiable
87
+ // for tokens issued before revocation
88
+ }
89
+ }
90
+
91
+ return { valid: true };
92
+ } catch (error) {
93
+ return {
94
+ valid: false,
95
+ error: error instanceof Error ? error.message : 'Unknown error',
96
+ };
97
+ }
98
+ }
package/src/wallet.ts ADDED
@@ -0,0 +1,227 @@
1
+ import * as jose from 'jose';
2
+ import type {
3
+ JWTWalletConfig,
4
+ JWTWalletJWKS,
5
+ AddSigningKeyOptions,
6
+ SigningKeyInfo,
7
+ WalletExport,
8
+ ValidationResult,
9
+ } from './types.js';
10
+ import { computeAccountId, buildSignedPayload, generateKid } from './utils.js';
11
+ import { validateJWKS } from './validate.js';
12
+
13
+ const DEFAULT_PLATFORM = 'jwtwallet.com';
14
+ const DEFAULT_ALGORITHM = 'ES256';
15
+ const WALLET_VERSION = 1;
16
+
17
+ export class JWTWallet {
18
+ readonly #platform: string;
19
+ readonly #algorithm: 'ES256' | 'ES384' | 'ES512';
20
+ #accountId: string;
21
+ #accountPublicKey: jose.JWK;
22
+ #accountPrivateKey: CryptoKey;
23
+ #signingKeys: Map<string, SigningKeyInfo> = new Map();
24
+ #revoked: Set<string> = new Set();
25
+
26
+ private constructor(
27
+ platform: string,
28
+ algorithm: 'ES256' | 'ES384' | 'ES512',
29
+ accountId: string,
30
+ accountPublicKey: jose.JWK,
31
+ accountPrivateKey: CryptoKey
32
+ ) {
33
+ this.#platform = platform;
34
+ this.#algorithm = algorithm;
35
+ this.#accountId = accountId;
36
+ this.#accountPublicKey = accountPublicKey;
37
+ this.#accountPrivateKey = accountPrivateKey;
38
+ }
39
+
40
+ /**
41
+ * Create a new JWTWallet with a fresh account key pair
42
+ */
43
+ static async create(config: JWTWalletConfig = {}): Promise<JWTWallet> {
44
+ const platform = config.platform ?? DEFAULT_PLATFORM;
45
+ const algorithm = config.algorithm ?? DEFAULT_ALGORITHM;
46
+
47
+ // Generate account key pair
48
+ const { publicKey, privateKey } = await jose.generateKeyPair(algorithm, {
49
+ extractable: true,
50
+ });
51
+
52
+ const accountPublicKey = await jose.exportJWK(publicKey);
53
+ // Add alg to public key for clarity
54
+ accountPublicKey.alg = algorithm;
55
+
56
+ const accountId = await computeAccountId(accountPublicKey);
57
+
58
+ return new JWTWallet(
59
+ platform,
60
+ algorithm,
61
+ accountId,
62
+ accountPublicKey,
63
+ privateKey as CryptoKey
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Import a wallet from exported data
69
+ */
70
+ static async import(data: WalletExport): Promise<JWTWallet> {
71
+ const algorithm = (data.accountKeyPair.publicKey.alg as 'ES256' | 'ES384' | 'ES512') ?? DEFAULT_ALGORITHM;
72
+
73
+ // Import the private key
74
+ const privateKey = await jose.importJWK(data.accountKeyPair.privateKey, algorithm);
75
+
76
+ const wallet = new JWTWallet(
77
+ data.platform,
78
+ algorithm,
79
+ data.accountId,
80
+ data.accountKeyPair.publicKey,
81
+ privateKey as CryptoKey
82
+ );
83
+
84
+ // Restore signing keys
85
+ for (const key of data.signingKeys) {
86
+ wallet.#signingKeys.set(key.kid, key);
87
+ }
88
+
89
+ // Restore revoked keys
90
+ for (const kid of data.revoked) {
91
+ wallet.#revoked.add(kid);
92
+ }
93
+
94
+ return wallet;
95
+ }
96
+
97
+ /** Account ID (subdomain) */
98
+ get accountId(): string {
99
+ return this.#accountId;
100
+ }
101
+
102
+ /** Full issuer URL */
103
+ get issuer(): string {
104
+ return `https://${this.#accountId}.${this.#platform}`;
105
+ }
106
+
107
+ /** Platform domain */
108
+ get platform(): string {
109
+ return this.#platform;
110
+ }
111
+
112
+ /** Account public key */
113
+ get accountPublicKey(): jose.JWK {
114
+ return { ...this.#accountPublicKey };
115
+ }
116
+
117
+ /** List of signing keys */
118
+ get signingKeys(): SigningKeyInfo[] {
119
+ return Array.from(this.#signingKeys.values());
120
+ }
121
+
122
+ /** List of revoked key IDs */
123
+ get revokedKeys(): string[] {
124
+ return Array.from(this.#revoked);
125
+ }
126
+
127
+ /**
128
+ * Add a signing key (public key only)
129
+ */
130
+ addSigningKey(options: AddSigningKeyOptions): SigningKeyInfo {
131
+ const kid = options.kid ?? generateKid();
132
+ const alg = options.alg ?? DEFAULT_ALGORITHM;
133
+
134
+ // Ensure the public key has kid and alg
135
+ const publicKey: jose.JWK = {
136
+ ...options.publicKey,
137
+ kid,
138
+ alg,
139
+ use: 'sig',
140
+ };
141
+
142
+ const keyInfo: SigningKeyInfo = { kid, alg, publicKey };
143
+ this.#signingKeys.set(kid, keyInfo);
144
+
145
+ return keyInfo;
146
+ }
147
+
148
+ /**
149
+ * Remove a signing key
150
+ */
151
+ removeSigningKey(kid: string): boolean {
152
+ return this.#signingKeys.delete(kid);
153
+ }
154
+
155
+ /**
156
+ * Revoke a signing key (adds to revoked list, does not remove from keys)
157
+ */
158
+ revokeKey(kid: string): void {
159
+ this.#revoked.add(kid);
160
+ }
161
+
162
+ /**
163
+ * Unrevoke a signing key
164
+ */
165
+ unrevokeKey(kid: string): boolean {
166
+ return this.#revoked.delete(kid);
167
+ }
168
+
169
+ /**
170
+ * Sign and export the JWKS with JWTWallet extension
171
+ */
172
+ async signAndExportJwtWalletJWKS(): Promise<JWTWalletJWKS> {
173
+ // Collect all public keys (excluding revoked from keys array is optional,
174
+ // but revoked list will be in the extension)
175
+ const keys = Array.from(this.#signingKeys.values()).map((k) => k.publicKey);
176
+
177
+ // Build the payload to sign
178
+ const payload = buildSignedPayload(keys, this.#accountPublicKey, this.issuer);
179
+ const payloadBytes = new TextEncoder().encode(payload);
180
+
181
+ // Sign with account private key
182
+ const signatureBytes = await crypto.subtle.sign(
183
+ { name: 'ECDSA', hash: 'SHA-256' },
184
+ this.#accountPrivateKey,
185
+ payloadBytes
186
+ );
187
+ const signature = jose.base64url.encode(new Uint8Array(signatureBytes));
188
+
189
+ return {
190
+ keys,
191
+ jwtwallet: {
192
+ version: WALLET_VERSION,
193
+ accountPublicKey: this.#accountPublicKey,
194
+ signature,
195
+ revoked: Array.from(this.#revoked),
196
+ },
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Export wallet for backup (includes account private key)
202
+ */
203
+ async export(): Promise<WalletExport> {
204
+ // Export private key
205
+ const privateKeyJWK = await crypto.subtle.exportKey('jwk', this.#accountPrivateKey);
206
+
207
+ return {
208
+ version: WALLET_VERSION,
209
+ accountId: this.#accountId,
210
+ platform: this.#platform,
211
+ accountKeyPair: {
212
+ publicKey: this.#accountPublicKey,
213
+ privateKey: privateKeyJWK as jose.JWK,
214
+ },
215
+ signingKeys: Array.from(this.#signingKeys.values()),
216
+ revoked: Array.from(this.#revoked),
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Validate the current wallet state
222
+ */
223
+ async validate(): Promise<ValidationResult> {
224
+ const jwks = await this.signAndExportJwtWalletJWKS();
225
+ return validateJWKS(jwks, this.issuer);
226
+ }
227
+ }