@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 +21 -0
- package/README.md +117 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/types.d.ts +44 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +15 -0
- package/dist/utils.js +27 -0
- package/dist/validate.d.ts +11 -0
- package/dist/validate.js +73 -0
- package/dist/wallet.d.ts +54 -0
- package/dist/wallet.js +163 -0
- package/package.json +58 -0
- package/src/index.ts +12 -0
- package/src/types.ts +61 -0
- package/src/utils.ts +38 -0
- package/src/validate.ts +98 -0
- package/src/wallet.ts +227 -0
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
|
package/dist/index.d.ts
ADDED
|
@@ -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
package/dist/types.d.ts
ADDED
|
@@ -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 {};
|
package/dist/utils.d.ts
ADDED
|
@@ -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>;
|
package/dist/validate.js
ADDED
|
@@ -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
|
+
}
|
package/dist/wallet.d.ts
ADDED
|
@@ -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
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -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
|
+
}
|