Skip to content

Commit 98635a0

Browse files
dickhardtclaude
andcommitted
Add browser support: CryptoKey signing, generateKeyPair, and browser-safe base64
- Accept CryptoKey handle for signing when JWK has no private key material - Add generateKeyPair() utility with extractable option - Replace Node.js Buffer with btoa/atob for browser compatibility Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 42929f3 commit 98635a0

6 files changed

Lines changed: 109 additions & 10 deletions

File tree

httpsig/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@hellocoop/httpsig",
3-
"version": "1.3.0",
3+
"version": "1.4.0",
44
"description": "HTTP Message Signatures (RFC 9421) with Signature-Key header support",
55
"repository": {
66
"type": "git",

httpsig/src/fetch.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export async function fetch(
9999
): Promise<Response | { headers: Headers }> {
100100
const {
101101
signingKey,
102+
signingCryptoKey,
102103
signatureKey,
103104
label = 'sig',
104105
components: customComponents,
@@ -109,12 +110,27 @@ export async function fetch(
109110
...fetchOptions
110111
} = options
111112

112-
// Validate signing key
113+
// Validate signing key JWK structure
113114
validateJwk(signingKey)
114115

115-
// Import private key
116-
const privateKey = await importPrivateKey(signingKey)
117-
const algorithm = getAlgorithmFromJwk(signingKey)
116+
// Determine private key and algorithm
117+
let privateKey: CryptoKey
118+
let algorithm: ReturnType<typeof getAlgorithmFromJwk>
119+
120+
if (signingKey.d) {
121+
// Full private JWK — import it
122+
privateKey = await importPrivateKey(signingKey)
123+
algorithm = getAlgorithmFromJwk(signingKey)
124+
} else {
125+
// Public-only JWK — must have a CryptoKey handle
126+
if (!signingCryptoKey) {
127+
throw new Error(
128+
'signingCryptoKey is required when signingKey does not contain private key material',
129+
)
130+
}
131+
privateKey = signingCryptoKey
132+
algorithm = getAlgorithmFromJwk(signingKey)
133+
}
118134

119135
// Get public key for hwk type
120136
const publicJwk = getPublicJwk(signingKey)

httpsig/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ export {
2020
parseAcceptSignature,
2121
} from './utils/signature.js'
2222

23+
export { generateKeyPair } from './utils/crypto.js'
24+
export type { GenerateKeyPairOptions, KeyPair } from './utils/crypto.js'
25+
2326
export type {
2427
HttpSigFetchOptions,
2528
SignatureKeyType,

httpsig/src/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,12 @@ export type SignatureKeyType =
5151
// See: https://github.com/PeculiarVentures/x509
5252

5353
export interface HttpSigFetchOptions extends RequestInit {
54-
// Required: Private key as JWK
54+
// Required: Key as JWK (private key with 'd' param, or public key without)
5555
signingKey: JsonWebKey
5656

57+
// Required when signingKey is a public JWK (no 'd' param): CryptoKey handle for signing
58+
signingCryptoKey?: CryptoKey
59+
5760
// Required: Signature-Key header configuration
5861
signatureKey: SignatureKeyType
5962

httpsig/src/utils/base64.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,37 @@
22
* Base64 encoding/decoding utilities for HTTP Message Signatures
33
*/
44

5+
/**
6+
* Convert Uint8Array to base64 string (browser-safe, no Buffer dependency)
7+
*/
8+
function bytesToBase64(bytes: Uint8Array): string {
9+
let binary = ''
10+
for (let i = 0; i < bytes.length; i++) {
11+
binary += String.fromCharCode(bytes[i])
12+
}
13+
return btoa(binary)
14+
}
15+
16+
/**
17+
* Convert base64 string to Uint8Array (browser-safe, no Buffer dependency)
18+
*/
19+
function base64ToBytes(base64: string): Uint8Array {
20+
const binary = atob(base64)
21+
const bytes = new Uint8Array(binary.length)
22+
for (let i = 0; i < binary.length; i++) {
23+
bytes[i] = binary.charCodeAt(i)
24+
}
25+
return bytes
26+
}
27+
528
/**
629
* Encode data to base64url format (RFC 4648 Section 5)
730
*/
831
export function base64urlEncode(data: Uint8Array | string): string {
932
const bytes =
1033
typeof data === 'string' ? new TextEncoder().encode(data) : data
1134

12-
const base64 = Buffer.from(bytes).toString('base64')
35+
const base64 = bytesToBase64(bytes)
1336

1437
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
1538
}
@@ -28,7 +51,7 @@ export function base64urlDecode(data: string): Uint8Array {
2851
// Convert base64url to base64
2952
const base64 = padded.replace(/-/g, '+').replace(/_/g, '/')
3053

31-
return new Uint8Array(Buffer.from(base64, 'base64'))
54+
return base64ToBytes(base64)
3255
}
3356

3457
/**
@@ -38,14 +61,14 @@ export function base64Encode(data: Uint8Array | string): string {
3861
const bytes =
3962
typeof data === 'string' ? new TextEncoder().encode(data) : data
4063

41-
return Buffer.from(bytes).toString('base64')
64+
return bytesToBase64(bytes)
4265
}
4366

4467
/**
4568
* Decode standard base64 to Uint8Array
4669
*/
4770
export function base64Decode(data: string): Uint8Array {
48-
return new Uint8Array(Buffer.from(data, 'base64'))
71+
return base64ToBytes(data)
4972
}
5073

5174
/**

httpsig/src/utils/crypto.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,60 @@ export async function verify(
7979
return await crypto.subtle.verify(algorithm, publicKey, signature, data)
8080
}
8181

82+
/**
83+
* Options for key pair generation
84+
*/
85+
export interface GenerateKeyPairOptions {
86+
algorithm?: 'Ed25519' | 'ES256' // default: 'Ed25519'
87+
extractable?: boolean // default: true
88+
}
89+
90+
/**
91+
* Generated key pair
92+
*/
93+
export interface KeyPair {
94+
privateKey: CryptoKey // CryptoKey handle for signing
95+
publicKey: JsonWebKey // Public key as JWK (always exportable)
96+
}
97+
98+
/**
99+
* Generate a signing key pair
100+
*/
101+
export async function generateKeyPair(
102+
options?: GenerateKeyPairOptions,
103+
): Promise<KeyPair> {
104+
const algorithm = options?.algorithm ?? 'Ed25519'
105+
const extractable = options?.extractable ?? true
106+
107+
let genAlgorithm:
108+
| AlgorithmIdentifier
109+
| RsaHashedKeyGenParams
110+
| EcKeyGenParams
111+
let keyUsages: KeyUsage[] = ['sign', 'verify']
112+
113+
if (algorithm === 'Ed25519') {
114+
genAlgorithm = { name: 'Ed25519' }
115+
} else if (algorithm === 'ES256') {
116+
genAlgorithm = { name: 'ECDSA', namedCurve: 'P-256' }
117+
} else {
118+
throw new Error(`Unsupported algorithm: ${algorithm}`)
119+
}
120+
121+
const keyPair = (await crypto.subtle.generateKey(
122+
genAlgorithm,
123+
extractable,
124+
keyUsages,
125+
)) as CryptoKeyPair
126+
127+
// Public key is always exportable
128+
const publicKey = await crypto.subtle.exportKey('jwk', keyPair.publicKey)
129+
130+
return {
131+
privateKey: keyPair.privateKey,
132+
publicKey,
133+
}
134+
}
135+
82136
/**
83137
* Validate JWK structure
84138
*/

0 commit comments

Comments
 (0)