phaedo-mcp 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.
@@ -0,0 +1,130 @@
1
+ // Encryption envelope per Phaedo Protocol spec §7.2.
2
+ //
3
+ // Wraps artifact bytes (fingerprint or user configuration) in AES-256-GCM
4
+ // with cleartext metadata bound as Additional Authenticated Data.
5
+ //
6
+ // Mirror of mobile/src/lib/envelope.ts. The two MUST produce bit-identical
7
+ // wire bytes for identical (plaintext, key, metadata) inputs.
8
+ //
9
+ // Loaded as a plain script after protocol.js. Exposes globalThis.Phaedo.Envelope.
10
+
11
+ (function () {
12
+ const P = globalThis.Phaedo.Protocol
13
+
14
+ // ── Encoding helpers ───────────────────────────────────────────────────────
15
+
16
+ function b64uEncode(bytes) {
17
+ let bin = ''
18
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
19
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
20
+ }
21
+
22
+ function b64uDecode(s) {
23
+ s = s.replace(/-/g, '+').replace(/_/g, '/')
24
+ while (s.length % 4) s += '='
25
+ const bin = atob(s)
26
+ const bytes = new Uint8Array(bin.length)
27
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
28
+ return bytes
29
+ }
30
+
31
+ /**
32
+ * Stable-key JSON stringify. Sufficient for the metadata AAD path, which
33
+ * contains only strings. NOT full RFC 8785 — when §8.3 signing lands and we
34
+ * canonicalize artifact bytes with numeric fields, switch to a JCS impl.
35
+ */
36
+ function canonicalStringify(value) {
37
+ if (value === null || typeof value !== 'object') return JSON.stringify(value)
38
+ if (Array.isArray(value)) {
39
+ return '[' + value.map(canonicalStringify).join(',') + ']'
40
+ }
41
+ const keys = Object.keys(value).sort()
42
+ return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalStringify(value[k])).join(',') + '}'
43
+ }
44
+
45
+ function utf8(s) { return new TextEncoder().encode(s) }
46
+
47
+ // ── Public API ─────────────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Wrap plaintext bytes in an envelope.
51
+ *
52
+ * @param plaintext Uint8Array of canonical artifact bytes
53
+ * @param key 32-byte AES-256 key derived via HKDF from an ECDH shared secret
54
+ * @param metadata Cleartext metadata; bound via GCM AAD
55
+ * @returns Promise<Envelope>
56
+ */
57
+ async function wrap(plaintext, key, metadata) {
58
+ if (key.length !== P.SIZES.AES_KEY) {
59
+ throw new Error(`envelope.wrap: key must be ${P.SIZES.AES_KEY} bytes, got ${key.length}`)
60
+ }
61
+
62
+ const iv = crypto.getRandomValues(new Uint8Array(P.SIZES.AES_IV))
63
+ const aad = utf8(canonicalStringify(metadata))
64
+
65
+ const aesKey = await crypto.subtle.importKey(
66
+ 'raw', key, 'AES-GCM', false, ['encrypt']
67
+ )
68
+ const ctAndTagBuf = await crypto.subtle.encrypt(
69
+ { name: 'AES-GCM', iv, additionalData: aad, tagLength: P.SIZES.AES_TAG * 8 },
70
+ aesKey,
71
+ plaintext
72
+ )
73
+ const ctAndTag = new Uint8Array(ctAndTagBuf)
74
+ const tagOffset = ctAndTag.length - P.SIZES.AES_TAG
75
+ const ciphertext = ctAndTag.subarray(0, tagOffset)
76
+ const tag = ctAndTag.subarray(tagOffset)
77
+
78
+ return {
79
+ phaedo_envelope_version: P.ENVELOPE_VERSION,
80
+ alg: P.ALG,
81
+ iv: b64uEncode(iv),
82
+ ciphertext: b64uEncode(ciphertext),
83
+ tag: b64uEncode(tag),
84
+ metadata,
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Unwrap an envelope. Rejects on version mismatch, algorithm mismatch, or
90
+ * authentication tag failure (treated as integrity failure per spec §8.1).
91
+ *
92
+ * @returns Promise<Uint8Array> plaintext bytes
93
+ */
94
+ async function unwrap(envelope, key) {
95
+ if (envelope.phaedo_envelope_version !== P.ENVELOPE_VERSION) {
96
+ throw new Error(`envelope.unwrap: unsupported version ${envelope.phaedo_envelope_version}`)
97
+ }
98
+ if (envelope.alg !== P.ALG) {
99
+ throw new Error(`envelope.unwrap: unsupported alg ${envelope.alg}`)
100
+ }
101
+ if (key.length !== P.SIZES.AES_KEY) {
102
+ throw new Error(`envelope.unwrap: key must be ${P.SIZES.AES_KEY} bytes, got ${key.length}`)
103
+ }
104
+
105
+ const iv = b64uDecode(envelope.iv)
106
+ const ct = b64uDecode(envelope.ciphertext)
107
+ const tag = b64uDecode(envelope.tag)
108
+ const aad = utf8(canonicalStringify(envelope.metadata))
109
+
110
+ if (iv.length !== P.SIZES.AES_IV) throw new Error(`envelope.unwrap: iv must be ${P.SIZES.AES_IV} bytes`)
111
+ if (tag.length !== P.SIZES.AES_TAG) throw new Error(`envelope.unwrap: tag must be ${P.SIZES.AES_TAG} bytes`)
112
+
113
+ const ctAndTag = new Uint8Array(ct.length + tag.length)
114
+ ctAndTag.set(ct, 0)
115
+ ctAndTag.set(tag, ct.length)
116
+
117
+ const aesKey = await crypto.subtle.importKey(
118
+ 'raw', key, 'AES-GCM', false, ['decrypt']
119
+ )
120
+ const ptBuf = await crypto.subtle.decrypt(
121
+ { name: 'AES-GCM', iv, additionalData: aad, tagLength: P.SIZES.AES_TAG * 8 },
122
+ aesKey,
123
+ ctAndTag
124
+ )
125
+ return new Uint8Array(ptBuf)
126
+ }
127
+
128
+ globalThis.Phaedo = globalThis.Phaedo || {}
129
+ globalThis.Phaedo.Envelope = { wrap, unwrap }
130
+ })()
@@ -0,0 +1,122 @@
1
+ // Long-term X25519 identity keypair for this Phaedo extension instance.
2
+ //
3
+ // The private key is the extension's persistent cryptographic identity.
4
+ // Used at pair time to derive a shared pair_key with a paired vault device,
5
+ // and at session start to authenticate the extension's ephemeral session pubkey.
6
+ //
7
+ // Persistence: stored INSIDE the vault (encrypted under the vault's AES-GCM
8
+ // session key, see vault.js). This means using the identity requires the
9
+ // vault to be unlocked. Acceptable because pairing and authorize flows are
10
+ // only meaningful when the user is actively present.
11
+ //
12
+ // Loaded as a plain script after vault.js. Exposes globalThis.Phaedo.Identity.
13
+
14
+ (function () {
15
+ const VAULT_FIELD = 'identity_v0.1' // sub-key inside the vault object
16
+
17
+ function b64uEncode(bytes) {
18
+ let bin = ''
19
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
20
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
21
+ }
22
+
23
+ function b64uDecode(s) {
24
+ s = s.replace(/-/g, '+').replace(/_/g, '/')
25
+ while (s.length % 4) s += '='
26
+ const bin = atob(s)
27
+ const bytes = new Uint8Array(bin.length)
28
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
29
+ return bytes
30
+ }
31
+
32
+ async function generate() {
33
+ const kp = await crypto.subtle.generateKey({ name: 'X25519' }, true, ['deriveBits'])
34
+ const publicKeyRaw = new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey))
35
+ const privateKeyJwk = await crypto.subtle.exportKey('jwk', kp.privateKey)
36
+ return { publicKey: publicKeyRaw, privateKey: kp.privateKey, privateKeyJwk }
37
+ }
38
+
39
+ async function importStored(stored) {
40
+ const publicKey = b64uDecode(stored.publicKey)
41
+ const privateKey = await crypto.subtle.importKey(
42
+ 'jwk', stored.privateKeyJwk, { name: 'X25519' }, false, ['deriveBits']
43
+ )
44
+ return { publicKey, privateKey }
45
+ }
46
+
47
+ /**
48
+ * Return the extension's identity, generating one on first call.
49
+ * Throws if the vault is locked — caller must unlock first.
50
+ *
51
+ * @returns {Promise<{ publicKey: Uint8Array, privateKey: CryptoKey }>}
52
+ */
53
+ async function getOrCreate() {
54
+ const vault = await globalThis.phaedoVault.read()
55
+ if (!vault) throw new Error('Phaedo.Identity: vault is locked')
56
+
57
+ if (vault[VAULT_FIELD]) {
58
+ return importStored(vault[VAULT_FIELD])
59
+ }
60
+
61
+ const fresh = await generate()
62
+ await globalThis.phaedoVault.update({
63
+ [VAULT_FIELD]: {
64
+ publicKey: b64uEncode(fresh.publicKey),
65
+ privateKeyJwk: fresh.privateKeyJwk,
66
+ },
67
+ })
68
+ return { publicKey: fresh.publicKey, privateKey: fresh.privateKey }
69
+ }
70
+
71
+ /**
72
+ * Compute the long-term ECDH shared secret with a peer's identity pubkey.
73
+ * @param {Uint8Array} peerPublicKey — 32 raw X25519 public key bytes
74
+ * @returns {Promise<Uint8Array>} 32-byte shared secret (NOT a session key — feed through HKDF)
75
+ */
76
+ async function deriveSharedWithPeer(peerPublicKey) {
77
+ if (peerPublicKey.length !== 32) {
78
+ throw new Error(`Phaedo.Identity.deriveSharedWithPeer: peer pubkey must be 32 bytes, got ${peerPublicKey.length}`)
79
+ }
80
+ const { privateKey } = await getOrCreate()
81
+ const peerKey = await crypto.subtle.importKey(
82
+ 'raw', peerPublicKey, { name: 'X25519' }, false, []
83
+ )
84
+ const shared = await crypto.subtle.deriveBits({ name: 'X25519', public: peerKey }, privateKey, 256)
85
+ return new Uint8Array(shared)
86
+ }
87
+
88
+ /** Just the public key. Convenience for QR display / pairing. */
89
+ async function getPublicKey() {
90
+ const { publicKey } = await getOrCreate()
91
+ return publicKey
92
+ }
93
+
94
+ /**
95
+ * Generate a fresh ephemeral X25519 keypair. Used per-session in the
96
+ * authorize handshake. The returned privateKey CryptoKey will be GC'd
97
+ * when its references drop — keep the closure scope tight.
98
+ */
99
+ async function generateEphemeral() {
100
+ const kp = await crypto.subtle.generateKey({ name: 'X25519' }, true, ['deriveBits'])
101
+ const publicKey = new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey))
102
+ return { publicKey, privateKey: kp.privateKey }
103
+ }
104
+
105
+ /** ECDH between an ephemeral private key and a peer's ephemeral pubkey bytes. */
106
+ async function deriveEphemeralShared(selfEphPrivate, peerEphPublicBytes) {
107
+ if (peerEphPublicBytes.length !== 32) {
108
+ throw new Error(`Phaedo.Identity.deriveEphemeralShared: peer eph pubkey must be 32 bytes, got ${peerEphPublicBytes.length}`)
109
+ }
110
+ const peerKey = await crypto.subtle.importKey(
111
+ 'raw', peerEphPublicBytes, { name: 'X25519' }, false, []
112
+ )
113
+ const shared = await crypto.subtle.deriveBits({ name: 'X25519', public: peerKey }, selfEphPrivate, 256)
114
+ return new Uint8Array(shared)
115
+ }
116
+
117
+ globalThis.Phaedo = globalThis.Phaedo || {}
118
+ globalThis.Phaedo.Identity = {
119
+ getOrCreate, deriveSharedWithPeer, getPublicKey,
120
+ generateEphemeral, deriveEphemeralShared,
121
+ }
122
+ })()
package/vendor/kdf.js ADDED
@@ -0,0 +1,114 @@
1
+ // Key derivation helpers using native WebCrypto HKDF-SHA-256.
2
+ //
3
+ // Mirror of mobile/src/lib/kdf.ts. Both sides produce identical bytes for
4
+ // identical inputs.
5
+ //
6
+ // Loaded as a plain script after protocol.js. Exposes globalThis.Phaedo.Kdf.
7
+
8
+ (function () {
9
+ const P = globalThis.Phaedo.Protocol
10
+
11
+ function utf8(s) { return new TextEncoder().encode(s) }
12
+
13
+ async function hkdfBytes(ikm, salt, info, outputLen) {
14
+ const key = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits'])
15
+ const bits = await crypto.subtle.deriveBits(
16
+ { name: 'HKDF', hash: 'SHA-256', salt, info },
17
+ key,
18
+ outputLen * 8,
19
+ )
20
+ return new Uint8Array(bits)
21
+ }
22
+
23
+ async function derivePairKey(sharedSecret, clientId) {
24
+ return hkdfBytes(
25
+ sharedSecret,
26
+ utf8(clientId),
27
+ utf8(P.HKDF_INFO.PAIR_KEY),
28
+ P.SIZES.AES_KEY,
29
+ )
30
+ }
31
+
32
+ /**
33
+ * Seed-transfer key for QR-free pairing (spec §8.4). HKDF-Expand of the
34
+ * SAS-verified pair_key under a distinct context, so the AES-GCM seed
35
+ * envelope never reuses pair_key's HMAC-in-authorize role. Re-derivable
36
+ * from the stored pair_key, so the seed ship step holds no extra state.
37
+ */
38
+ async function deriveSeedKey(pairKey) {
39
+ return hkdfBytes(
40
+ pairKey,
41
+ new Uint8Array(0),
42
+ utf8(P.HKDF_INFO.SEED_KEY),
43
+ P.SIZES.AES_KEY,
44
+ )
45
+ }
46
+
47
+ async function deriveSessionKey(ephemeralShared, sessionToken) {
48
+ return hkdfBytes(
49
+ ephemeralShared,
50
+ utf8(sessionToken),
51
+ utf8(P.HKDF_INFO.SESSION_KEY),
52
+ P.SIZES.AES_KEY,
53
+ )
54
+ }
55
+
56
+ function compareBytes(a, b) {
57
+ const n = Math.min(a.length, b.length)
58
+ for (let i = 0; i < n; i++) {
59
+ if (a[i] !== b[i]) return a[i] - b[i]
60
+ }
61
+ return a.length - b.length
62
+ }
63
+
64
+ async function deriveSAS(pubA, pubB) {
65
+ const [first, second] = compareBytes(pubA, pubB) <= 0 ? [pubA, pubB] : [pubB, pubA]
66
+ const combined = new Uint8Array(first.length + second.length)
67
+ combined.set(first, 0)
68
+ combined.set(second, first.length)
69
+ return hkdfBytes(
70
+ combined,
71
+ new Uint8Array(0),
72
+ utf8(P.HKDF_INFO.SAS_FINGERPRINT),
73
+ P.SIZES.SAS_BYTES,
74
+ )
75
+ }
76
+
77
+ const EPH_AUTH_LABEL = 'phaedo-eph-auth-v0.1'
78
+
79
+ /**
80
+ * HMAC-SHA-256(pair_key, role_label || ephPub).
81
+ * Binds the ephemeral exchange in authorize to the established pair_key.
82
+ * @param role 'ext' or 'vault' — prevents MAC replay across directions.
83
+ */
84
+ async function authEphemeral(pairKey, role, ephPub) {
85
+ const label = utf8(`${EPH_AUTH_LABEL}|${role}|`)
86
+ const msg = new Uint8Array(label.length + ephPub.length)
87
+ msg.set(label, 0)
88
+ msg.set(ephPub, label.length)
89
+ const macKey = await crypto.subtle.importKey(
90
+ 'raw', pairKey, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']
91
+ )
92
+ return new Uint8Array(await crypto.subtle.sign('HMAC', macKey, msg))
93
+ }
94
+
95
+ /** Constant-time MAC verification. */
96
+ async function verifyAuthEphemeral(pairKey, role, ephPub, expectedMac) {
97
+ const computed = await authEphemeral(pairKey, role, ephPub)
98
+ if (computed.length !== expectedMac.length) return false
99
+ let diff = 0
100
+ for (let i = 0; i < computed.length; i++) diff |= computed[i] ^ expectedMac[i]
101
+ return diff === 0
102
+ }
103
+
104
+ /** Format SAS bytes as a human-readable "XX-XX-XX-XX" hex string. */
105
+ function formatSAS(sasBytes) {
106
+ return Array.from(sasBytes, b => b.toString(16).padStart(2, '0').toUpperCase()).join('-')
107
+ }
108
+
109
+ globalThis.Phaedo = globalThis.Phaedo || {}
110
+ globalThis.Phaedo.Kdf = {
111
+ derivePairKey, deriveSeedKey, deriveSessionKey, deriveSAS, formatSAS,
112
+ authEphemeral, verifyAuthEphemeral,
113
+ }
114
+ })()
@@ -0,0 +1,56 @@
1
+ // Phaedo Protocol constants. Single source of truth on the extension side.
2
+ // Matches mobile/src/lib/protocol.ts — keep both in sync.
3
+ // See docs/protocol/v0.1.md for the authoritative spec.
4
+ //
5
+ // Loaded as a plain script before vault.js and popup.js. Exposes globalThis.Phaedo.Protocol.
6
+
7
+ (function () {
8
+ const Protocol = {
9
+ /** Phaedo Protocol version. Sent on the wire in pairing payloads. */
10
+ VERSION: '0.1.1',
11
+
12
+ /** Envelope format version. Goes into phaedo_envelope_version field per spec §7.2. */
13
+ ENVELOPE_VERSION: '0.1',
14
+
15
+ /** Portable export wrapper version. Goes into phaedo_export_version per spec §8.2. */
16
+ EXPORT_VERSION: '0.1',
17
+
18
+ /** Encryption algorithm. v0.1 normative: AES-256-GCM only. Spec §7.1. */
19
+ ALG: 'AES-256-GCM',
20
+
21
+ /**
22
+ * HKDF info labels. Domain separation between key derivation contexts.
23
+ * MUST be byte-identical on mobile and extension or keys will diverge.
24
+ */
25
+ HKDF_INFO: {
26
+ PAIR_KEY: 'phaedo-pair-key-v0.1',
27
+ SESSION_KEY: 'phaedo-session-v0.1',
28
+ SAS_FINGERPRINT: 'phaedo-sas-v0.1',
29
+ // Seed-transfer key for QR-free pairing (spec §8.4). A sub-key of
30
+ // pair_key so the GCM seed envelope never reuses pair_key's HMAC role.
31
+ SEED_KEY: 'phaedo-seed-v0.1',
32
+ },
33
+
34
+ /** Sizes in bytes — fixed by spec §7.2. */
35
+ SIZES: {
36
+ X25519_PUBKEY: 32,
37
+ X25519_PRIVKEY: 32,
38
+ AES_KEY: 32,
39
+ AES_IV: 12,
40
+ AES_TAG: 16,
41
+ HKDF_SALT: 16,
42
+ SAS_BYTES: 4,
43
+ },
44
+
45
+ /** Artifact kind tags in envelope metadata. Spec §7.2. */
46
+ ARTIFACT_KIND: {
47
+ FINGERPRINT: 'fingerprint',
48
+ USER_CONFIGURATION: 'user_configuration',
49
+ EXCHANGE_BATCH: 'exchange_batch', // Loop 2 qualifying-session batch (the local-pass payload shape)
50
+ },
51
+ }
52
+
53
+ // Attach to globalThis so content scripts, popup, and interview adapters all see it.
54
+ globalThis.Phaedo = globalThis.Phaedo || {}
55
+ globalThis.Phaedo.Protocol = Protocol
56
+ })()