Skip to content

Commit 0cc259a

Browse files
committed
unseal decode
1 parent fab6f67 commit 0cc259a

File tree

2 files changed

+137
-1
lines changed

2 files changed

+137
-1
lines changed

src/common/crypto/seal.spec.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { sealData, unsealData } from './seal';
2+
3+
describe('seal', () => {
4+
const password = 'test-password-at-least-32-characters-long';
5+
6+
describe('sealData', () => {
7+
it('seals data and appends version delimiter', async () => {
8+
const data = { foo: 'bar' };
9+
const sealed = await sealData(data, { password });
10+
11+
expect(sealed).toContain('~2');
12+
expect(sealed.startsWith('Fe26.2*')).toBe(true);
13+
});
14+
15+
it('seals with TTL', async () => {
16+
const data = { foo: 'bar' };
17+
const sealed = await sealData(data, { password, ttl: 3600 });
18+
19+
expect(sealed).toContain('~2');
20+
});
21+
});
22+
23+
describe('unsealData', () => {
24+
it('round-trips seal/unseal', async () => {
25+
const data = { userId: '123', email: '[email protected]' };
26+
const sealed = await sealData(data, { password });
27+
const unsealed = await unsealData<typeof data>(sealed, { password });
28+
29+
expect(unsealed).toEqual(data);
30+
});
31+
32+
it('round-trips complex nested data', async () => {
33+
const data = {
34+
user: { id: '123', name: 'Test' },
35+
roles: ['admin', 'user'],
36+
metadata: { nested: { deep: true } },
37+
};
38+
const sealed = await sealData(data, { password });
39+
const unsealed = await unsealData<typeof data>(sealed, { password });
40+
41+
expect(unsealed).toEqual(data);
42+
});
43+
44+
it('handles seals with TTL (expiration embedded in seal)', async () => {
45+
const data = { foo: 'bar' };
46+
// Seal with TTL - expiration timestamp is embedded in the seal
47+
const sealed = await sealData(data, { password, ttl: 3600 });
48+
49+
// Verify expiration field is present (5th component, non-empty)
50+
const parts = sealed.split('~')[0].split('*');
51+
expect(parts[5]).toMatch(/^\d+$/); // Non-empty expiration timestamp
52+
53+
// Should unseal successfully within TTL window
54+
const unsealed = await unsealData(sealed, { password });
55+
expect(unsealed).toEqual(data);
56+
});
57+
58+
it('returns empty object for bad password', async () => {
59+
const data = { foo: 'bar' };
60+
const sealed = await sealData(data, { password });
61+
const unsealed = await unsealData(sealed, {
62+
password: 'wrong-password-at-least-32-characters',
63+
});
64+
65+
expect(unsealed).toEqual({});
66+
});
67+
68+
it('returns empty object for tampered seal', async () => {
69+
const data = { foo: 'bar' };
70+
const sealed = await sealData(data, { password });
71+
// Tamper with the sealed data
72+
const tampered = sealed.replace('Fe26.2', 'Fe26.2tampered');
73+
const unsealed = await unsealData(tampered, { password });
74+
75+
expect(unsealed).toEqual({});
76+
});
77+
78+
it('handles legacy tokens with persistent field (version 1)', async () => {
79+
// Manually construct a v1-style token that would have persistent wrapper
80+
const data = { persistent: { userId: '123' } };
81+
const sealed = await sealData(data, { password });
82+
// Replace ~2 with ~1 to simulate v1 token
83+
const v1Sealed = sealed.replace('~2', '~1');
84+
85+
const unsealed = await unsealData<{ userId: string }>(v1Sealed, {
86+
password,
87+
});
88+
expect(unsealed).toEqual({ userId: '123' });
89+
});
90+
91+
it('handles tokens without version delimiter', async () => {
92+
const data = { foo: 'bar' };
93+
const sealed = await sealData(data, { password });
94+
// Remove version delimiter to simulate pre-versioned token
95+
const noVersion = sealed.split('~')[0];
96+
97+
const unsealed = await unsealData<typeof data>(noVersion, { password });
98+
expect(unsealed).toEqual(data);
99+
});
100+
});
101+
102+
describe('iron-session compatibility', () => {
103+
// This test verifies that tokens sealed with this implementation
104+
// produce the same format as iron-session (Fe26.2 prefix with ~2 suffix)
105+
it('produces iron-session compatible format', async () => {
106+
const data = { userId: '123' };
107+
const sealed = await sealData(data, { password });
108+
109+
// Verify format: Fe26.2*...*~2
110+
const parts = sealed.split('~');
111+
expect(parts).toHaveLength(2);
112+
expect(parts[1]).toBe('2');
113+
114+
const ironPart = parts[0];
115+
expect(ironPart.startsWith('Fe26.2*')).toBe(true);
116+
117+
// Iron format has 8 asterisk-delimited components
118+
const components = ironPart.split('*');
119+
expect(components).toHaveLength(8);
120+
expect(components[0]).toBe('Fe26.2');
121+
});
122+
123+
// Verify that tokens can be round-tripped and maintain the expected format
124+
// This ensures compatibility with iron-session which uses the same Fe26.2 format
125+
it('can unseal self-generated tokens (simulates iron-session compatibility)', async () => {
126+
const data = { userId: '123', email: '[email protected]' };
127+
const sealed = await sealData(data, { password });
128+
129+
// Verify the format matches iron-session's expected output
130+
expect(sealed).toMatch(/^Fe26\.2\*1\*[^*]+\*[^*]+\*[^*]+\*\*[^*]+\*[^*]+~2$/);
131+
132+
const unsealed = await unsealData<typeof data>(sealed, { password });
133+
expect(unsealed).toEqual(data);
134+
});
135+
});
136+
});

src/common/crypto/seal.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export async function unsealData<T = unknown>(
6464
} catch (error) {
6565
if (
6666
error instanceof Error &&
67-
/^(Expired seal|Bad hmac value|Cannot find password|Incorrect number of sealed components)/.test(
67+
/^(Expired seal|Bad hmac value|Cannot find password|Incorrect number of sealed components|Wrong mac prefix)/.test(
6868
error.message,
6969
)
7070
) {

0 commit comments

Comments
 (0)