@tinfoilsh/passkey-kit 0.1.0 → 0.2.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/README.md +138 -81
- package/dist/crypto.d.ts +11 -54
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +146 -97
- package/dist/crypto.js.map +1 -1
- package/dist/errors.d.ts +11 -21
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +10 -27
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +7 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -7
- package/dist/index.js.map +1 -1
- package/dist/kit.d.ts +2 -62
- package/dist/kit.d.ts.map +1 -1
- package/dist/kit.js +305 -159
- package/dist/kit.js.map +1 -1
- package/dist/storage.d.ts +15 -29
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +113 -62
- package/dist/storage.js.map +1 -1
- package/dist/support.d.ts +2 -14
- package/dist/support.d.ts.map +1 -1
- package/dist/support.js +32 -47
- package/dist/support.js.map +1 -1
- package/dist/types.d.ts +85 -89
- package/dist/types.d.ts.map +1 -1
- package/dist/webauthn.d.ts +10 -33
- package/dist/webauthn.d.ts.map +1 -1
- package/dist/webauthn.js +89 -177
- package/dist/webauthn.js.map +1 -1
- package/dist/wrapped-key-record-codec.d.ts +4 -0
- package/dist/wrapped-key-record-codec.d.ts.map +1 -0
- package/dist/wrapped-key-record-codec.js +89 -0
- package/dist/wrapped-key-record-codec.js.map +1 -0
- package/package.json +4 -2
- package/src/crypto.ts +171 -137
- package/src/errors.ts +29 -33
- package/src/index.ts +26 -41
- package/src/kit.ts +405 -263
- package/src/storage.ts +126 -60
- package/src/support.ts +36 -50
- package/src/types.ts +99 -99
- package/src/webauthn.ts +121 -237
- package/src/wrapped-key-record-codec.ts +93 -0
- package/dist/protocol.d.ts +0 -13
- package/dist/protocol.d.ts.map +0 -1
- package/dist/protocol.js +0 -13
- package/dist/protocol.js.map +0 -1
- package/src/protocol.ts +0 -15
package/README.md
CHANGED
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
# Tinfoil Passkey Kit
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
wrapped by the other.
|
|
3
|
+
JavaScript and Swift libraries for wrapping 32-byte keys with passkeys and the
|
|
4
|
+
WebAuthn PRF extension. Both implementations use the same profile-driven wire
|
|
5
|
+
format and can recover records produced by the other.
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
- AES-256-GCM CEK wrapping and unwrapping
|
|
11
|
-
- Stable CEK key-ID derivation
|
|
12
|
-
- Device-local PRF and credential persistence
|
|
7
|
+
This kit performs local key wrapping. It does not log users in, authenticate a
|
|
8
|
+
session to a server, or provide hosted storage.
|
|
13
9
|
|
|
14
|
-
## JavaScript
|
|
10
|
+
## JavaScript quickstart
|
|
15
11
|
|
|
16
12
|
Install the browser package:
|
|
17
13
|
|
|
@@ -19,109 +15,170 @@ Install the browser package:
|
|
|
19
15
|
npm install @tinfoilsh/passkey-kit
|
|
20
16
|
```
|
|
21
17
|
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
Create one explicit version 1 profile and keep every field stable for existing
|
|
19
|
+
records. The display name belongs to manager configuration, not the profile.
|
|
24
20
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
```ts
|
|
22
|
+
import {
|
|
23
|
+
createPasskeyKeyManager,
|
|
24
|
+
decodeWrappedKeyRecord,
|
|
25
|
+
encodeWrappedKeyRecord,
|
|
26
|
+
} from "@tinfoilsh/passkey-kit";
|
|
27
|
+
|
|
28
|
+
const encoder = new TextEncoder();
|
|
29
|
+
const profile = {
|
|
30
|
+
version: 1,
|
|
31
|
+
relyingPartyId: "example.com",
|
|
32
|
+
prfSalt: encoder.encode("example-key-wrapping"),
|
|
33
|
+
hkdfInfo: encoder.encode("example-wrapping-key-v1"),
|
|
34
|
+
};
|
|
35
|
+
const manager = createPasskeyKeyManager({
|
|
36
|
+
profile,
|
|
37
|
+
relyingPartyName: "Example App",
|
|
28
38
|
});
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
user: {
|
|
33
|
-
|
|
39
|
+
const key = crypto.getRandomValues(new Uint8Array(32));
|
|
40
|
+
|
|
41
|
+
const created = await manager.createAndWrapKey({
|
|
42
|
+
user: {
|
|
43
|
+
id: crypto.getRandomValues(new Uint8Array(32)),
|
|
44
|
+
name: "person@example.com",
|
|
45
|
+
displayName: "Example Person",
|
|
46
|
+
},
|
|
47
|
+
key,
|
|
34
48
|
});
|
|
49
|
+
await wrappedKeyRepository.save(encodeWrappedKeyRecord(created.wrappedKey));
|
|
35
50
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
51
|
+
const records: string[] = await wrappedKeyRepository.list();
|
|
52
|
+
const wrappedKeys = records.map(decodeWrappedKeyRecord);
|
|
53
|
+
const recovered = await manager.recoverKey({ wrappedKeys });
|
|
54
|
+
useKey(recovered.key);
|
|
55
|
+
```
|
|
39
56
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
57
|
+
`evaluateCredential` is available for advanced migrations that need direct PRF
|
|
58
|
+
evaluation:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
const evaluated = await manager.evaluateCredential({
|
|
62
|
+
credentialIds: wrappedKeys.map(({ credentialId }) => credentialId),
|
|
63
|
+
});
|
|
64
|
+
usePRFOutput(evaluated.prfResult.output);
|
|
65
|
+
evaluated.prfResult.output.fill(0);
|
|
44
66
|
```
|
|
45
67
|
|
|
46
|
-
|
|
47
|
-
`
|
|
48
|
-
|
|
49
|
-
|
|
68
|
+
Treat PRF output as secret key material and prefer `recoverKey` for normal
|
|
69
|
+
recovery. `wrappedKeyRepository` is application-owned. See the minimal
|
|
70
|
+
[repository interface](https://github.com/tinfoilsh/tinfoil-passkey-kit/blob/main/docs/wrapped-key-repository.md)
|
|
71
|
+
and runnable [web example](https://github.com/tinfoilsh/tinfoil-passkey-kit/blob/main/Examples/Web/README.md).
|
|
50
72
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
`
|
|
73
|
+
Ceremony failures throw `PasskeyKeyError`. Branch on its stable `category`, not
|
|
74
|
+
its message. Local PRF caching is disabled unless the application supplies a
|
|
75
|
+
`PasskeyKeyStorage`; cached PRF output is secret key material.
|
|
54
76
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
77
|
+
Persistence is disabled by default. Cached recovery and rewrap require an
|
|
78
|
+
explicit synchronous `PasskeyKeyStorage`. Cached PRF output is raw secret key
|
|
79
|
+
material and requires host-appropriate protection.
|
|
58
80
|
|
|
59
|
-
|
|
81
|
+
The memory adapter is suitable for tests. The explicitly named
|
|
82
|
+
`createInsecureBrowserLocalStoragePasskeyKeyStorage(namespace)` adapter stores
|
|
83
|
+
raw PRF output unencrypted, isolates records by its required namespace, and is
|
|
84
|
+
insecure because same-origin scripts can read the cached secret material.
|
|
85
|
+
|
|
86
|
+
`evaluateCredential` exposes raw PRF output for advanced migrations. Treat
|
|
87
|
+
`prfResult.output` as secret key material and prefer `recoverKey` for normal
|
|
88
|
+
recovery. `wrapKeyWithPRFResult` and `unwrapKeyWithPRFResult` perform explicit
|
|
89
|
+
crypto-only operations without starting a ceremony or accessing storage.
|
|
90
|
+
|
|
91
|
+
## Swift quickstart
|
|
60
92
|
|
|
61
93
|
Add this repository as a Swift Package Manager dependency and link the
|
|
62
94
|
`TinfoilPasskeyKit` product. The package requires iOS 18 or macOS 15.
|
|
63
95
|
|
|
64
96
|
```swift
|
|
97
|
+
import Foundation
|
|
65
98
|
import TinfoilPasskeyKit
|
|
66
99
|
|
|
67
100
|
@MainActor
|
|
68
|
-
func
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
101
|
+
func protectKey(
|
|
102
|
+
_ key: Data,
|
|
103
|
+
presentationAnchorProvider: any PasskeyPresentationAnchorProviding
|
|
104
|
+
) async throws {
|
|
105
|
+
let profile = try PasskeyKeyProfile(
|
|
106
|
+
version: 1,
|
|
107
|
+
relyingPartyId: "example.com",
|
|
108
|
+
prfSalt: Data("example-key-wrapping".utf8),
|
|
109
|
+
hkdfInfo: Data("example-wrapping-key-v1".utf8)
|
|
73
110
|
)
|
|
74
|
-
let
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
stateStore: store
|
|
79
|
-
)
|
|
111
|
+
let manager = try PasskeyKeyManager(
|
|
112
|
+
profile: profile,
|
|
113
|
+
relyingPartyName: "Example App",
|
|
114
|
+
presentationAnchorProvider: presentationAnchorProvider
|
|
80
115
|
)
|
|
81
116
|
|
|
82
|
-
let
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
117
|
+
let created = try await manager.createAndWrapKey(
|
|
118
|
+
user: PasskeyUser(
|
|
119
|
+
id: opaqueUserHandle,
|
|
120
|
+
name: "person@example.com",
|
|
121
|
+
displayName: "Example Person"
|
|
122
|
+
),
|
|
123
|
+
key: key
|
|
86
124
|
)
|
|
87
|
-
await
|
|
125
|
+
await wrappedKeyRepository.save(try encodeWrappedKeyRecord(created.wrappedKey))
|
|
88
126
|
|
|
89
|
-
let
|
|
90
|
-
|
|
127
|
+
let records: [Data] = await wrappedKeyRepository.list()
|
|
128
|
+
let wrappedKeys = try records.map(decodeWrappedKeyRecord)
|
|
129
|
+
let recovered = try await manager.recoverKey(wrappedKeys: wrappedKeys)
|
|
130
|
+
useKey(recovered.key)
|
|
91
131
|
}
|
|
92
132
|
```
|
|
93
133
|
|
|
94
|
-
|
|
95
|
-
`
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
134
|
+
Advanced flows can call `evaluateCredential(credentialIds:interaction:)`.
|
|
135
|
+
Its `prfResult.output` is raw secret key material. Do not log, transmit, or
|
|
136
|
+
retain it longer than necessary.
|
|
137
|
+
|
|
138
|
+
Apple hosts must pass a `PasskeyPresentationAnchorProviding` implementation to
|
|
139
|
+
the manager as `presentationAnchorProvider`. The provider returns the iOS or
|
|
140
|
+
macOS window AuthenticationServices uses for interactive presentation. Making
|
|
141
|
+
it required prevents constructing a manager that cannot present a ceremony.
|
|
142
|
+
AuthenticationServices does not accept the configured `relyingPartyName`; Apple
|
|
143
|
+
derives relying-party presentation from system and associated-domain metadata.
|
|
144
|
+
|
|
145
|
+
Persistence is disabled by default. `KeychainPasskeyKeyStorage` is an optional
|
|
146
|
+
generic Apple adapter that stores cached PRF output with
|
|
147
|
+
`kSecAttrAccessibleWhenUnlockedThisDeviceOnly`. Its records are device-bound and
|
|
148
|
+
unavailable while the device is locked, but any process context that can read
|
|
149
|
+
the item can recover keys without another passkey prompt. Choose storage based
|
|
150
|
+
on the host app's threat model. The host app must also provide the
|
|
151
|
+
`webcredentials` associated-domain entitlement for its relying-party domain.
|
|
152
|
+
Keychain operations are synchronous and can block the manager's main actor.
|
|
153
|
+
|
|
154
|
+
On iOS 18 and macOS 15, recovery supports platform and synced passkeys,
|
|
155
|
+
including Apple's cross-device passkey flow. Explicit security-key PRF is not
|
|
156
|
+
currently enabled in the Apple target because the baseline toolchain does not
|
|
157
|
+
provide that API. Browsers may support security-key or hybrid recovery.
|
|
158
|
+
Capability remains `unknown` when Apple cannot preflight PRF support; callers
|
|
159
|
+
should allow an attempt.
|
|
160
|
+
|
|
161
|
+
See the compilable [Apple example](https://github.com/tinfoilsh/tinfoil-passkey-kit/blob/main/Examples/Apple/README.md).
|
|
162
|
+
|
|
163
|
+
## Documentation
|
|
164
|
+
|
|
165
|
+
- [API contract](https://github.com/tinfoilsh/tinfoil-passkey-kit/blob/main/docs/api-contract.md)
|
|
166
|
+
- [Support matrix](https://github.com/tinfoilsh/tinfoil-passkey-kit/blob/main/docs/support-matrix.md)
|
|
167
|
+
- [Security boundary](https://github.com/tinfoilsh/tinfoil-passkey-kit/blob/main/docs/security-boundary.md)
|
|
168
|
+
- [Scope](https://github.com/tinfoilsh/tinfoil-passkey-kit/blob/main/docs/scope.md)
|
|
169
|
+
- [Contributing](https://github.com/tinfoilsh/tinfoil-passkey-kit/blob/main/CONTRIBUTING.md)
|
|
170
|
+
- [Security policy](https://github.com/tinfoilsh/tinfoil-passkey-kit/blob/main/SECURITY.md)
|
|
118
171
|
|
|
119
172
|
## Development
|
|
120
173
|
|
|
121
174
|
```sh
|
|
122
|
-
npm
|
|
175
|
+
npm ci
|
|
123
176
|
npm test
|
|
124
177
|
npm run typecheck
|
|
125
178
|
npm run build
|
|
126
179
|
swift test
|
|
127
180
|
```
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
Apache License 2.0. See [LICENSE](LICENSE).
|
package/dist/crypto.d.ts
CHANGED
|
@@ -1,55 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export declare
|
|
11
|
-
|
|
12
|
-
export declare function generateCek(): Uint8Array;
|
|
13
|
-
/**
|
|
14
|
-
* Type guard for a well-formed CEK: a Uint8Array of exactly
|
|
15
|
-
* {@link CEK_BYTES} bytes. Useful for validating deserialized input
|
|
16
|
-
* before wrapping.
|
|
17
|
-
*/
|
|
18
|
-
export declare function isValidCek(cek: unknown): cek is Uint8Array;
|
|
19
|
-
/**
|
|
20
|
-
* Derive an AES-256-GCM Key Encryption Key (KEK) from PRF output using HKDF.
|
|
21
|
-
*
|
|
22
|
-
* Raw PRF output is treated as Input Keying Material (IKM), not used
|
|
23
|
-
* directly as a key. HKDF with a purpose-binding info string produces the
|
|
24
|
-
* final non-extractable CryptoKey. An empty HKDF salt is used, which is
|
|
25
|
-
* fine for high-entropy IKM (RFC 5869 §3.1).
|
|
26
|
-
*
|
|
27
|
-
* `hkdfInfo` defaults to the Tinfoil v1 protocol constant so standalone
|
|
28
|
-
* callers derive the same interoperable KEK as a default-configured kit.
|
|
29
|
-
*/
|
|
30
|
-
export declare function deriveKeyEncryptionKey(prfOutput: ArrayBuffer | Uint8Array, hkdfInfo?: string | Uint8Array): Promise<CryptoKey>;
|
|
31
|
-
/**
|
|
32
|
-
* Wrap a raw 32-byte CEK under a passkey-derived KEK using AES-256-GCM
|
|
33
|
-
* with a fresh random IV. The returned hex fields are safe to persist
|
|
34
|
-
* server-side; only the matching passkey can recover the CEK.
|
|
35
|
-
*/
|
|
36
|
-
export declare function wrapCek(opts: {
|
|
37
|
-
credentialId: string;
|
|
38
|
-
kek: CryptoKey;
|
|
39
|
-
cek: Uint8Array;
|
|
40
|
-
}): Promise<WrappedCek>;
|
|
41
|
-
/**
|
|
42
|
-
* Inverse of {@link wrapCek}: recover the raw CEK bytes given the same KEK.
|
|
43
|
-
* Throws on tamper (GCM auth failure) or any shape mismatch.
|
|
44
|
-
*/
|
|
45
|
-
export declare function unwrapCek(kek: CryptoKey, wrapped: Pick<WrappedCek, "kekIvHex" | "wrappedKeyHex">): Promise<Uint8Array>;
|
|
46
|
-
/**
|
|
47
|
-
* Derive a stable public identifier for a CEK via HKDF-SHA-256 with an
|
|
48
|
-
* empty salt and a purpose-binding info string. The result identifies the key
|
|
49
|
-
* without revealing it (one-way derivation).
|
|
50
|
-
*/
|
|
51
|
-
export declare function deriveKeyId(cek: Uint8Array, opts?: {
|
|
52
|
-
info?: string | Uint8Array;
|
|
53
|
-
lengthBytes?: number;
|
|
54
|
-
}): Promise<Uint8Array>;
|
|
1
|
+
import type { PasskeyKeyProfile, WrappedKey } from "./types.js";
|
|
2
|
+
export declare const PROFILE_KEYS: readonly ["version", "relyingPartyId", "prfSalt", "hkdfInfo"];
|
|
3
|
+
export declare function copyAndValidateProfile(profile: PasskeyKeyProfile): PasskeyKeyProfile;
|
|
4
|
+
export declare function profilesEqual(left: PasskeyKeyProfile, right: PasskeyKeyProfile): boolean;
|
|
5
|
+
export declare function decodeCanonicalBase64Url(value: unknown, field: string): Uint8Array;
|
|
6
|
+
export declare function validateCredentialId(credentialId: string): void;
|
|
7
|
+
export declare function validateKey(key: Uint8Array, operation?: string): void;
|
|
8
|
+
export declare function validateWrappedKey(wrapped: WrappedKey, profile: PasskeyKeyProfile): void;
|
|
9
|
+
export declare function deriveWrappingKey(prfOutput: Uint8Array, profile: PasskeyKeyProfile): Promise<CryptoKey>;
|
|
10
|
+
export declare function wrapKey(profile: PasskeyKeyProfile, credentialId: string, prfOutput: Uint8Array, key: Uint8Array, operation?: string): Promise<WrappedKey>;
|
|
11
|
+
export declare function unwrapKey(profile: PasskeyKeyProfile, prfOutput: Uint8Array, wrapped: WrappedKey, operation?: string): Promise<Uint8Array>;
|
|
55
12
|
//# sourceMappingURL=crypto.d.ts.map
|
package/dist/crypto.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAMhE,eAAO,MAAM,YAAY,+DAKf,CAAC;AAQX,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,iBAAiB,GAAG,iBAAiB,CAqBpF;AAED,wBAAgB,aAAa,CAC3B,IAAI,EAAE,iBAAiB,EACvB,KAAK,EAAE,iBAAiB,GACvB,OAAO,CAST;AAED,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,UAAU,CAmBlF;AAED,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAE/D;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAIrE;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,iBAAiB,GAAG,IAAI,CAmBxF;AAED,wBAAsB,iBAAiB,CACrC,SAAS,EAAE,UAAU,EACrB,OAAO,EAAE,iBAAiB,GACzB,OAAO,CAAC,SAAS,CAAC,CAwBpB;AAED,wBAAsB,OAAO,CAC3B,OAAO,EAAE,iBAAiB,EAC1B,YAAY,EAAE,MAAM,EACpB,SAAS,EAAE,UAAU,EACrB,GAAG,EAAE,UAAU,EACf,SAAS,SAAqB,GAC7B,OAAO,CAAC,UAAU,CAAC,CAqBrB;AAED,wBAAsB,SAAS,CAC7B,OAAO,EAAE,iBAAiB,EAC1B,SAAS,EAAE,UAAU,EACrB,OAAO,EAAE,UAAU,EACnB,SAAS,SAAe,GACvB,OAAO,CAAC,UAAU,CAAC,CAgBrB"}
|
package/dist/crypto.js
CHANGED
|
@@ -1,105 +1,154 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
* References:
|
|
6
|
-
* - W3C WebAuthn Level 3, §10.1.4 (PRF extension): https://w3c.github.io/webauthn/#prf-extension
|
|
7
|
-
* - RFC 5869 (HKDF): https://tools.ietf.org/html/rfc5869
|
|
8
|
-
*/
|
|
9
|
-
import { bytesToHex, hexToBytes, toBytes } from "./codec.js";
|
|
10
|
-
import { PasskeyKitError } from "./errors.js";
|
|
11
|
-
import { TINFOIL_HKDF_INFO_V1, TINFOIL_KEY_ID_INFO_V1 } from "./protocol.js";
|
|
12
|
-
export const CEK_BYTES = 32;
|
|
1
|
+
import { base64UrlToBytes, bytesToBase64Url, bytesToHex, hexToBytes, } from "./codec.js";
|
|
2
|
+
import { invalidInput, operationFailed, PasskeyKeyError } from "./errors.js";
|
|
3
|
+
const KEY_BYTES = 32;
|
|
4
|
+
const PRF_OUTPUT_BYTES = 32;
|
|
13
5
|
const AES_GCM_IV_BYTES = 12;
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return cek instanceof Uint8Array && cek.length === CEK_BYTES;
|
|
26
|
-
}
|
|
27
|
-
/**
|
|
28
|
-
* Derive an AES-256-GCM Key Encryption Key (KEK) from PRF output using HKDF.
|
|
29
|
-
*
|
|
30
|
-
* Raw PRF output is treated as Input Keying Material (IKM), not used
|
|
31
|
-
* directly as a key. HKDF with a purpose-binding info string produces the
|
|
32
|
-
* final non-extractable CryptoKey. An empty HKDF salt is used, which is
|
|
33
|
-
* fine for high-entropy IKM (RFC 5869 §3.1).
|
|
34
|
-
*
|
|
35
|
-
* `hkdfInfo` defaults to the Tinfoil v1 protocol constant so standalone
|
|
36
|
-
* callers derive the same interoperable KEK as a default-configured kit.
|
|
37
|
-
*/
|
|
38
|
-
export async function deriveKeyEncryptionKey(prfOutput, hkdfInfo = TINFOIL_HKDF_INFO_V1) {
|
|
39
|
-
const masterKey = await crypto.subtle.importKey("raw", prfOutput, "HKDF", false, // non-extractable
|
|
40
|
-
["deriveKey"]);
|
|
41
|
-
return crypto.subtle.deriveKey({
|
|
42
|
-
name: "HKDF",
|
|
43
|
-
hash: "SHA-256",
|
|
44
|
-
salt: new Uint8Array(),
|
|
45
|
-
info: toBytes(hkdfInfo),
|
|
46
|
-
}, masterKey, { name: "AES-GCM", length: 256 }, false, // non-extractable
|
|
47
|
-
["encrypt", "decrypt"]);
|
|
6
|
+
const AES_GCM_TAG_BYTES = 16;
|
|
7
|
+
export const PROFILE_KEYS = [
|
|
8
|
+
"version",
|
|
9
|
+
"relyingPartyId",
|
|
10
|
+
"prfSalt",
|
|
11
|
+
"hkdfInfo",
|
|
12
|
+
];
|
|
13
|
+
function assertBytes(value, name, allowEmpty = false) {
|
|
14
|
+
if (!(value instanceof Uint8Array) || (!allowEmpty && value.length === 0)) {
|
|
15
|
+
throw invalidInput(`${name} must be ${allowEmpty ? "a" : "a non-empty"} Uint8Array`);
|
|
16
|
+
}
|
|
48
17
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
18
|
+
export function copyAndValidateProfile(profile) {
|
|
19
|
+
if (!profile || typeof profile !== "object")
|
|
20
|
+
throw invalidInput("profile is required");
|
|
21
|
+
const keys = Object.keys(profile).sort();
|
|
22
|
+
const expected = [...PROFILE_KEYS].sort();
|
|
23
|
+
if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
|
|
24
|
+
throw invalidInput(`profile must contain exactly ${PROFILE_KEYS.join(", ")}`);
|
|
25
|
+
}
|
|
26
|
+
if (profile.version !== 1) {
|
|
27
|
+
throw invalidInput("profile.version must be 1");
|
|
28
|
+
}
|
|
29
|
+
if (typeof profile.relyingPartyId !== "string" || profile.relyingPartyId.length === 0) {
|
|
30
|
+
throw invalidInput("profile.relyingPartyId must be a non-empty string");
|
|
31
|
+
}
|
|
32
|
+
assertBytes(profile.prfSalt, "profile.prfSalt");
|
|
33
|
+
assertBytes(profile.hkdfInfo, "profile.hkdfInfo");
|
|
60
34
|
return {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
35
|
+
version: profile.version,
|
|
36
|
+
relyingPartyId: profile.relyingPartyId,
|
|
37
|
+
prfSalt: profile.prfSalt.slice(),
|
|
38
|
+
hkdfInfo: profile.hkdfInfo.slice(),
|
|
64
39
|
};
|
|
65
40
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
41
|
+
export function profilesEqual(left, right) {
|
|
42
|
+
return (left.version === right.version &&
|
|
43
|
+
left.relyingPartyId === right.relyingPartyId &&
|
|
44
|
+
left.prfSalt.length === right.prfSalt.length &&
|
|
45
|
+
left.prfSalt.every((byte, index) => byte === right.prfSalt[index]) &&
|
|
46
|
+
left.hkdfInfo.length === right.hkdfInfo.length &&
|
|
47
|
+
left.hkdfInfo.every((byte, index) => byte === right.hkdfInfo[index]));
|
|
48
|
+
}
|
|
49
|
+
export function decodeCanonicalBase64Url(value, field) {
|
|
50
|
+
if (typeof value !== "string" ||
|
|
51
|
+
value.length === 0 ||
|
|
52
|
+
!/^[A-Za-z0-9_-]+$/.test(value) ||
|
|
53
|
+
value.length % 4 === 1) {
|
|
54
|
+
throw invalidInput(`${field} must be unpadded base64url`);
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const bytes = base64UrlToBytes(value);
|
|
58
|
+
if (bytes.length === 0 || bytesToBase64Url(bytes) !== value) {
|
|
59
|
+
throw invalidInput(`${field} must use canonical unpadded base64url`);
|
|
60
|
+
}
|
|
61
|
+
return bytes;
|
|
62
|
+
}
|
|
63
|
+
catch (cause) {
|
|
64
|
+
if (cause instanceof PasskeyKeyError)
|
|
65
|
+
throw cause;
|
|
66
|
+
throw invalidInput(`${field} must be unpadded base64url`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function validateCredentialId(credentialId) {
|
|
70
|
+
decodeCanonicalBase64Url(credentialId, "credentialId");
|
|
85
71
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
export
|
|
92
|
-
if (
|
|
93
|
-
throw
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
|
|
72
|
+
export function validateKey(key, operation) {
|
|
73
|
+
if (!(key instanceof Uint8Array) || key.length !== KEY_BYTES) {
|
|
74
|
+
throw invalidInput(`key must be exactly ${KEY_BYTES} bytes`, operation);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function validateWrappedKey(wrapped, profile) {
|
|
78
|
+
if (!wrapped || typeof wrapped !== "object")
|
|
79
|
+
throw invalidInput("wrapped key is required");
|
|
80
|
+
const keys = Object.keys(wrapped).sort();
|
|
81
|
+
const expected = ["profile", "credentialId", "kekIvHex", "wrappedKeyHex"].sort();
|
|
82
|
+
if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
|
|
83
|
+
throw invalidInput("wrapped key has unexpected fields");
|
|
84
|
+
}
|
|
85
|
+
const wrappedProfile = copyAndValidateProfile(wrapped.profile);
|
|
86
|
+
if (!profilesEqual(wrappedProfile, profile)) {
|
|
87
|
+
throw invalidInput("wrapped key profile mismatch");
|
|
88
|
+
}
|
|
89
|
+
validateCredentialId(wrapped.credentialId);
|
|
90
|
+
if (!/^[0-9a-f]{24}$/.test(wrapped.kekIvHex)) {
|
|
91
|
+
throw invalidInput("kekIvHex must be a lowercase 12-byte hex value");
|
|
92
|
+
}
|
|
93
|
+
const ciphertextHexLength = (KEY_BYTES + AES_GCM_TAG_BYTES) * 2;
|
|
94
|
+
if (!new RegExp(`^[0-9a-f]{${ciphertextHexLength}}$`).test(wrapped.wrappedKeyHex)) {
|
|
95
|
+
throw invalidInput("wrappedKeyHex has an invalid format or length");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export async function deriveWrappingKey(prfOutput, profile) {
|
|
99
|
+
if (!(prfOutput instanceof Uint8Array) || prfOutput.length !== PRF_OUTPUT_BYTES) {
|
|
100
|
+
throw invalidInput(`PRF output must be exactly ${PRF_OUTPUT_BYTES} bytes`);
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const ikm = await crypto.subtle.importKey("raw", prfOutput.slice(), "HKDF", false, [
|
|
104
|
+
"deriveKey",
|
|
105
|
+
]);
|
|
106
|
+
return await crypto.subtle.deriveKey({
|
|
107
|
+
name: "HKDF",
|
|
108
|
+
hash: "SHA-256",
|
|
109
|
+
salt: new Uint8Array(),
|
|
110
|
+
info: profile.hkdfInfo,
|
|
111
|
+
}, ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
|
|
112
|
+
}
|
|
113
|
+
catch (cause) {
|
|
114
|
+
if (cause instanceof PasskeyKeyError)
|
|
115
|
+
throw cause;
|
|
116
|
+
throw operationFailed("failed to derive wrapping key", cause);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export async function wrapKey(profile, credentialId, prfOutput, key, operation = "createAndWrapKey") {
|
|
120
|
+
validateCredentialId(credentialId);
|
|
121
|
+
validateKey(key, operation);
|
|
122
|
+
try {
|
|
123
|
+
const wrappingKey = await deriveWrappingKey(prfOutput, profile);
|
|
124
|
+
const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_BYTES));
|
|
125
|
+
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, wrappingKey, key);
|
|
126
|
+
return {
|
|
127
|
+
profile: copyAndValidateProfile(profile),
|
|
128
|
+
credentialId,
|
|
129
|
+
kekIvHex: bytesToHex(iv),
|
|
130
|
+
wrappedKeyHex: bytesToHex(new Uint8Array(ciphertext)),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
catch (cause) {
|
|
134
|
+
if (cause instanceof PasskeyKeyError)
|
|
135
|
+
throw cause;
|
|
136
|
+
throw operationFailed("failed to wrap key", cause, operation);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
export async function unwrapKey(profile, prfOutput, wrapped, operation = "recoverKey") {
|
|
140
|
+
validateWrappedKey(wrapped, profile);
|
|
141
|
+
try {
|
|
142
|
+
const wrappingKey = await deriveWrappingKey(prfOutput, profile);
|
|
143
|
+
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv: hexToBytes(wrapped.kekIvHex) }, wrappingKey, hexToBytes(wrapped.wrappedKeyHex));
|
|
144
|
+
const key = new Uint8Array(plaintext);
|
|
145
|
+
validateKey(key, operation);
|
|
146
|
+
return key;
|
|
147
|
+
}
|
|
148
|
+
catch (cause) {
|
|
149
|
+
if (cause instanceof PasskeyKeyError)
|
|
150
|
+
throw cause;
|
|
151
|
+
throw operationFailed("failed to recover key", cause, operation);
|
|
152
|
+
}
|
|
104
153
|
}
|
|
105
154
|
//# sourceMappingURL=crypto.js.map
|